Authenticate to OAuth2 and OIDC services
When a workflow calls a protected HTTP or OpenAPI service, it usually needs an Authorization: Bearer <token> header.
Instead of fetching those tokens yourself, Quarkus Flow can negotiate them for you:
you declare an OAuth2/OIDC authentication on the call, and the engine performs the token request against the authorization server and attaches it to the downstream request automatically.
This guide shows how to:
-
Enable OAuth2/OIDC token negotiation in a Quarkus Flow application.
-
Attach a token to an HTTP or OpenAPI call using the most common grants (Client Credentials, OIDC, Password, Token Exchange).
-
Reuse a single authentication across several calls, and use multiple authorization servers in one workflow.
-
Keep client credentials out of your workflow code using secrets.
A complete, runnable project covering every scenario below lives in examples/auth-oauth2-oidc.
|
Prerequisites
-
A Quarkus application with Quarkus Flow set up.
-
Familiarity with HTTP and OpenAPI tasks and secrets.
1. Add the Flow OIDC extension
Add quarkus-flow-oidc to your project to enable OAuth2/OIDC authentication in workflows:
<dependency>
<groupId>io.quarkiverse.flow</groupId>
<artifactId>quarkus-flow-oidc</artifactId>
</dependency>
This extension brings quarkus-oidc-client as a transitive dependency and delegates token negotiation to it.
To opt out, set quarkus.flow.oidc.enabled=false (see the [configuration-reference]).
|
Quarkus Flow does not cache or refresh tokens: a new token is requested from the authorization server on every authenticated call. What is cached and reused is the underlying Registration timing:
YAML or JSON workflow definitions placed in See Client caching and lifecycle for complete details. |
2. Attach an authentication to a call
You can attach an authentication inline on a single call, or declare it once under use(…) and reference it by name from many calls.
Inline on a single call
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.*;
http("listImages")
.GET()
.header("Accept", "application/json")
.uri(URI.create(imageService),
oauth2(baseUrl, CLIENT_CREDENTIALS, clientId, clientSecret)); (1)
| 1 | oauth2(…) attaches the authentication directly to this call. Use oidc(…) for an OpenID Connect client. |
Declare once under use(…) and reference by name
If several calls share the same authority, declare the authentication once and reference it by name:
import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.*;
workflow("orders")
.use(use -> use.authentications(auth ->
auth.authentication("inventory", a -> a.oauth2(o -> o
.authority(baseUrl)
.grant(CLIENT_CREDENTIALS)
.client(c -> c.id(clientId).secret(clientSecret))))))
.tasks(
http("reserve").GET()
.uri(URI.create(inventory + "/reserve"), use("inventory")), (1)
http("confirm").GET()
.uri(URI.create(inventory + "/confirm"), use("inventory")))
.build();
| 1 | use("inventory") references the named authentication declared above. |
3. Use the Client Credentials grant
If you need machine-to-machine authentication, use the Client Credentials grant. Read credentials from secrets with ${ $secret.* } expressions rather than hardcoding them:
@ApplicationScoped
public class ClientCredentialsFlow extends Flow {
@ConfigProperty(name = "image.service.url")
String imageService;
@ConfigProperty(name = "clientCredentials.baseUrl")
String baseUrl;
@Override
public Workflow descriptor() {
return FuncWorkflowBuilder.workflow("client-credentials", "quarkus-flow")
.use(u -> u.secrets("clientCredentials")) (1)
.tasks(
FuncDSL.call(
FuncDSL.http("listImages")
.GET()
.header("Accept", "application/json")
.uri(URI.create(imageService),
FuncDSL.oauth2(baseUrl, CLIENT_CREDENTIALS,
"${ $secret.clientCredentials.clientId }",
"${ $secret.clientCredentials.clientSecret }")))) (2)
.build();
}
}
| 1 | Declares the clientCredentials secret group, making its values available to the workflow. |
| 2 | The id and secret are resolved from that group at runtime. |
The secret values must be supplied externally. For local development and tests, application.properties is the simplest option:
clientCredentials.clientId=quarkus-flow
clientCredentials.clientSecret=dummy-client-secret
clientCredentials.baseUrl=https://auth.example.com/realms/test/protocol/openid-connect/token
In production, the same secret names resolve through any Quarkus CredentialsProvider (HashiCorp Vault, Kubernetes Secrets, etc.) without changing the workflow code. See Resolve secrets securely for details.
4. Use an OIDC client
If the authorization server is an OpenID Connect provider, use oidc(…) instead of oauth2(…):
FuncDSL.http("listImages")
.GET()
.header("Accept", "application/json")
.uri(URI.create(imageService),
FuncDSL.oauth2(baseUrl, CLIENT_CREDENTIALS, (1)
"${ $secret.oidcClient.\"client-id\" }",
"${ $secret.oidcClient.\"client-secret\" }",
e -> e.token("/protocol/openid-connect/token"))); (2)
| 1 | For OIDC with discovery, use FuncDSL.oidc() - it discovers the token endpoint automatically. |
| 2 | For OAuth2, explicitly specify the token endpoint path. When a secret key contains a dash, quote it inside the JQ path: ${ $secret.oidcClient.\"client-id\" }. |
5. Use the Password grant
If the service requires a username and password alongside client credentials, use the full oauth2(…) builder:
FuncDSL.http()
.method("DELETE")
.uri(URI.create(imageService + "/attrs/" + id),
FuncDSL.oauth2(oauth2 -> oauth2
.authority(baseUrl)
.grant(PASSWORD)
.client(c -> c.id("${ $secret.\"password-grant\".\"client-id\" }")
.secret("${ $secret.\"password-grant\".\"client-secret\" }"))
.username("${ $secret.\"password-grant\".username }") (1)
.password("${ $secret.\"password-grant\".password }")));
| 1 | Username and password are resolved at runtime and passed as dynamic grant parameters to the token endpoint alongside client credentials. |
6. Use Token Exchange (RFC 8693)
If you need to exchange a subject (and optional actor) token for a new one scoped to a target audience, use the token-exchange grant:
FuncDSL.oauth2(oauth2 -> oauth2
.endpoints(token -> token.token("/oauth2/token"))
.client(c -> c.id("my-client").secret("my-secret"))
.subject(s -> s.token("${ $secret.exchangeSecrets.subjectToken }")
.type("urn:ietf:params:oauth:token-type:access_token"))
.actor(a -> a.token("${ $secret.exchangeSecrets.actorToken }")
.type("urn:ietf:params:oauth:token-type:access_token"))
.scopes("api")
.audiences("target-service")
.grant(URN_IETF_PARAMS_OAUTH_GRANT_TYPE_TOKEN_EXCHANGE)
.authority(baseUrl));
7. Call multiple authorization servers
If your workflow calls services protected by different authorization servers, declare each authentication under use(…) and reference them by name:
return FuncWorkflowBuilder.workflow("multiple-oauth2-clients", "quarkus-flow")
.use(use -> use.secrets("joogle", "jahoo") (1)
.authentications(auth -> {
auth.authentication("joogle", a -> a.oauth2(o -> o
.endpoints(e -> e.token("/auth/joogle/token"))
.client(c -> c.id("${ $secret.joogle.clientId }")
.secret("${ $secret.joogle.clientSecret }")
.authentication(CLIENT_SECRET_POST))
.authority(joogleBaseUrl)
.grant(CLIENT_CREDENTIALS)));
auth.authentication("jahoo", a -> a.oauth2(o -> o
.endpoints(e -> e.token("/auth/jahoo/oidc/token"))
.client(c -> c.id("${ $secret.jahoo.clientId }")
.secret("${ $secret.jahoo.clientSecret }")
.authentication(CLIENT_SECRET_POST))
.authority(jahooBaseUrl)
.grant(CLIENT_CREDENTIALS)));
}))
.tasks(
FuncDSL.fork( (2)
FuncDSL.http("getEmailsFromJoogle").GET()
.uri(URI.create(wireMock + "/joogle/inbox"), FuncDSL.use("joogle")),
FuncDSL.http("getEmailsFromJahoo").GET()
.uri(URI.create(wireMock + "/jahoo/inbox"), FuncDSL.use("jahoo"))))
.build();
| 1 | Declare the secret groups for both clients together. |
| 2 | fork runs the two authenticated calls in parallel; each picks up its own token. |
8. Authenticate OpenAPI operations
If you use OpenAPI tasks, supply the credentials through oidc(…) (or oauth2(…)):
FuncWorkflowBuilder.workflow()
.use(u -> u.secrets("openapi"))
.tasks(t -> t.openapi("imageService", f -> f
.document(openApiDocumentUrl)
.operation("listImages")
.parameters(Map.of("Accept", "application/json"))
.authentication(FuncDSL.oidc(baseUrl, CLIENT_CREDENTIALS,
"${ $secret.openapi.\"client-id\" }",
"${ $secret.openapi.\"client-secret\" }"))))
.build();
9. Provide credentials
Credentials are supplied through secrets as shown in section 3. In production, the same secret names resolve through any Quarkus CredentialsProvider (HashiCorp Vault, Kubernetes Secrets, etc.) without changing the workflow code. See Resolve secrets securely for details.
10. Route to a named Quarkus OIDC client
When a workflow declares an oauth2(…) or oidc(…) authentication in the DSL, Quarkus Flow builds an OIDC client from those values automatically. You can redirect any workflow or task to use a pre-configured Quarkus OIDC client instead, giving you full control over the client configuration — different auth server per environment, token expiry tuning, custom headers — without changing the workflow code.
How to route a workflow to a named client
-
Define a standard Quarkus OIDC client in
application.properties:quarkus.oidc-client.prod-auth.auth-server-url=https://auth.prod.example.com quarkus.oidc-client.prod-auth.token-path=/oauth2/token quarkus.oidc-client.prod-auth.client-id=my-service quarkus.oidc-client.prod-auth.credentials.secret=${OIDC_CLIENT_SECRET} quarkus.oidc-client.prod-auth.grant.type=client quarkus.oidc-client.prod-auth.discovery-enabled=false -
Route the workflow to that client by mapping the workflow’s identifier to the client name:
quarkus.flow.oidc.client."myns\:my-workflow".name=prod-auth
When the workflow runs, Quarkus Flow uses the prod-auth OIDC client instead of building one from the DSL. The DSL authentication still declares which calls are authenticated — the routing only controls how the token is obtained.
How to route a specific task
If a single task in a workflow needs a different OIDC client, use the task-level routing syntax:
quarkus.flow.oidc.client."myns\:my-workflow\:1.0.0.task.callSlowApi".name=slow-api-auth
Only the callSlowApi task uses slow-api-auth. All other tasks in the same workflow fall through to the next matching level or use the DSL-derived client.
How to route by named authentication policy
If the DSL declares a named authentication with use(…):
workflow("orders")
.use(use -> use.authentications(auth ->
auth.authentication("keycloak", a -> a.oauth2(o -> o ...))))
.tasks(
http("reserve").GET()
.uri(URI.create(url), use("keycloak")))
.build();
You can route all calls that reference "keycloak" to a named Quarkus OIDC client:
quarkus.flow.oidc.client.keycloak.name=prod-auth
This is useful when multiple workflows share the same named authentication and you want to control the OIDC client configuration in one place.
How to add authentication entirely from configuration
A workflow can call an HTTP endpoint without declaring any oauth2(…) or oidc(…) in the DSL, and have the token negotiation added purely from application.properties. Route the workflow (or a specific task) to a named Quarkus OIDC client:
# Define the OIDC client
quarkus.oidc-client.internal-auth.auth-server-url=https://auth.internal.example.com
quarkus.oidc-client.internal-auth.token-path=/oauth2/token
quarkus.oidc-client.internal-auth.client-id=my-service
quarkus.oidc-client.internal-auth.credentials.secret=${OIDC_SECRET}
quarkus.oidc-client.internal-auth.grant.type=client
quarkus.oidc-client.internal-auth.discovery-enabled=false
# Route by versionless workflow identity — applies to all versions
quarkus.flow.oidc.client."myns\:my-workflow".name=internal-auth
# Or route a specific version
quarkus.flow.oidc.client."myns\:my-workflow\:2.0.0".name=internal-auth
# Or route a specific task within a versioned workflow
quarkus.flow.oidc.client."myns\:my-workflow\:1.0.0.task.fetchData".name=internal-auth
The workflow code stays free of credentials and auth-server URLs. Environment-specific OIDC configuration lives entirely in application.properties (or Kubernetes ConfigMaps, Vault, etc.).
Resolution order
When multiple routing rules match, the most specific one wins:
-
Task-level (most specific) —
quarkus.flow.oidc.client."<namespace>\:<name>\:<version>.task.<taskName>".name -
Versioned workflow —
quarkus.flow.oidc.client."<namespace>\:<name>\:<version>".name -
Versionless workflow —
quarkus.flow.oidc.client."<namespace>\:<name>".name -
Named authentication —
quarkus.flow.oidc.client.<authPolicyName>.name -
DSL fallback — no routing configured; an OIDC client is created from the DSL authentication
|
Progressive specificity: The resolver tries versions from shortest to longest: first just the workflow name, then namespace:name, then namespace:name:version. For each level, task-level config takes precedence over workflow-level config. |
|
Colons in the composite key must be escaped as |
All OIDC client tuning (client authentication, custom headers, TLS, proxy, etc.) is configured in the standard quarkus.oidc-client.<name>.* namespace. See the Quarkus OIDC Client reference for the full set of available properties.
|
Routing also changes who manages the client’s lifecycle. A named client is created at startup and closed on shutdown by the |
Client caching and lifecycle
This section explains what Quarkus Flow caches, for how long, and who is responsible for the OidcClient instances behind your authenticated calls. As stated in section 1, tokens are never cached: a new token is requested from the authorization server on every authenticated call.
When is an OidcClient cached and reused?
Quarkus Flow maintains a registry of OIDC clients with dual lookup capability:
-
By name — Used for named policies (
use("keycloak")) and routing overrides -
By endpoint configuration — Used for matching identical OAuth2/OIDC configs
Registration timing:
-
Build-time (YAML/JSON workflows in classpath, no expressions): Client configuration generated at build time as
quarkus.oidc-client.*properties, clients created byquarkus-oidc-clientextension at startup -
Startup (Java DSL workflows, no expressions): Clients registered at application startup by Quarkus Flow
-
Runtime (any workflow with expressions like
${ $secret.xxx }): Clients registered lazily on first use after expression resolution
Cache key (EndpointKey): The endpoint-based lookup uses a composite key covering all OAuth2/OIDC configuration fields:
-
Authority URL
-
Token endpoint path (OAuth2 only; OIDC uses discovery)
-
Revocation endpoint path
-
OAuth2 vs OIDC flag (discovery enabled/disabled)
-
Client ID and client secret
-
Client authentication method (
CLIENT_SECRET_POST,CLIENT_SECRET_BASIC, etc.) -
Grant type (
CLIENT_CREDENTIALS,PASSWORD,TOKEN_EXCHANGE, etc.) -
Scopes and audiences
-
Username and password (PASSWORD grant only)
Two call sites share a cached client only when every field matches. If any field differs — even a single scope — a separate OidcClient is created:
-
Same authority, same client, same grant, same scopes → one cached client
-
Same authority, same client, different scopes → two cached clients
-
Same authority, different token endpoint paths → two cached clients
-
oauth2(…)vsoidc(…)for the same authority → two cached clients (different discovery flag) -
Named policy
use("keycloak")referenced by 3 tasks → one cached client (looked up by name)
All cached clients are closed automatically on application shutdown.
Who manages the lifecycle of these clients?
Quarkus Flow creates OIDC clients at three different times:
-
Build-time configuration (YAML/JSON workflows in classpath): Policies without runtime expressions (
${ … }) in YAML or JSON workflow files on the classpath are converted to standardquarkus.oidc-client.*configuration properties at build time. Thequarkus-oidc-clientextension then creates and manages those clients at startup — Quarkus Flow simply looks them up by name at runtime.To use your own pre-configured OIDC client instead of the workflow-generated one, use routing configuration:
# Your pre-configured client quarkus.oidc-client.myProdClient.auth-server-url=https://prod.example.com quarkus.oidc-client.myProdClient.client-id=prod-client-id ... # Route the workflow's "myauth" policy to use your client quarkus.flow.oidc.client.myauth.name=myProdClientThis approach works for both YAML/JSON workflows (build-time) and Java DSL workflows (startup), and is the recommended way to override authentication configuration without modifying the workflow definition.
See section 10 for complete routing examples.
-
Startup registration (Java DSL workflows): Policies without runtime expressions in Java DSL workflows are registered when the workflow is first loaded via
OidcClients.newClient(…) -
Runtime registration (any workflow with expressions): Policies with expressions are registered lazily after expression resolution on first HTTP request via
OidcClients.newClient(…)
Clients created via startup registration and runtime registration are not registered with the quarkus-oidc-client extension: Quarkus does not track them, does not close them on shutdown, and does not schedule any background token refresh for them. Quarkus Flow therefore owns their full lifecycle.
Clients created via build-time configuration (YAML/JSON workflows) are registered with the quarkus-oidc-client extension and managed by Quarkus throughout their lifecycle — Quarkus Flow only looks them up by name.
For startup and runtime registered clients:
-
Creation — Built using the application’s managed Vert.x instance and standard Quarkus TLS/proxy registries, so TLS and proxy configuration work exactly as for static
quarkus.oidc-client.*clients -
Caching — Stored in Quarkus Flow’s internal registry with dual lookup (by name + by EndpointKey). Only the client is cached, never the tokens it negotiates
-
Expression resolution — For dynamic clients,
${ $secret.xxx }expressions are resolved at runtime using workflow context (accessing Quarkus Vault, Kubernetes Secrets, etc.) -
Token negotiation — A new token is requested from the authorization server every time a call needs an
Authorizationheader. There is no token caching, no expiry tracking, no background refresh;refresh-intervalhas no effect on these clients -
Dynamic grant parameters — PASSWORD and TOKEN_EXCHANGE grants resolve username/password or subject/actor tokens from expressions and pass them to the token endpoint at request time
-
Shutdown — Quarkus Flow closes every cached client when the application shuts down
If you prefer clients whose configuration is fully managed by the quarkus-oidc-client extension, define a named client in application.properties and route the workflow to it, as described in section 10. Named clients are created and closed by the quarkus-oidc-client extension itself — but note that Quarkus Flow still requests a new token per call even when routing to a named client.
Best practices
This section covers recommended patterns, common pitfalls, and practical advice for OAuth2/OIDC authentication in production workflows.
Security
Always use secrets, never hardcode credentials
Use secrets with ${ $secret.* } expressions to keep credentials out of your workflow code:
// ❌ BAD - Hardcoded credentials in code
.oauth2(authority, CLIENT_CREDENTIALS, "my-client-id", "hardcoded-secret-123")
// ✅ GOOD - Credentials from secrets
.oauth2(authority, CLIENT_CREDENTIALS,
"${ $secret.oauth.clientId }",
"${ $secret.oauth.clientSecret }")
Configure secrets in application.properties:
oauth.clientId=my-client-id
oauth.clientSecret=${OAUTH_CLIENT_SECRET} # From environment variable
Separate dev/prod clients via routing
Keep workflows environment-agnostic by using routing configuration:
// Workflow stays the same across environments
workflow("orders")
.use(use -> use.authentications(auth ->
auth.authentication("payment-api", a -> a.oauth2(...))))
.tasks(http("charge").uri(uri, use("payment-api")))
# application-dev.properties
quarkus.flow.oidc.client.payment-api.name=payment-api-dev
# application-prod.properties
quarkus.flow.oidc.client.payment-api.name=payment-api-prod
Use different clients for different environments
Pre-configure named OIDC clients per environment and route to them:
# Dev client - localhost auth server
quarkus.oidc-client.payment-api-dev.auth-server-url=http://localhost:8089
quarkus.oidc-client.payment-api-dev.client-id=dev-client
# Prod client - production auth server
quarkus.oidc-client.payment-api-prod.auth-server-url=https://auth.prod.example.com
quarkus.oidc-client.payment-api-prod.client-id=prod-client
quarkus.oidc-client.payment-api-prod.credentials.secret=${PROD_SECRET}
Performance
Use named policies to share OIDC clients
When multiple tasks call the same protected service, declare authentication once under use(…) and reference by name:
// ❌ BAD - Inline auth creates 3 separate OIDC clients
workflow("orders")
.tasks(
http("reserve").uri(inventory, oauth2(...)), // Client 1
http("confirm").uri(inventory, oauth2(...)), // Client 2
http("release").uri(inventory, oauth2(...))) // Client 3
// ✅ GOOD - Named policy creates 1 shared OIDC client
workflow("orders")
.use(use -> use.authentications(auth ->
auth.authentication("inventory", a -> a.oauth2(...))))
.tasks(
http("reserve").uri(inventory, use("inventory")), // Shared client
http("confirm").uri(inventory, use("inventory")), // Shared client
http("release").uri(inventory, use("inventory"))) // Shared client
Why inline auth creates isolated clients: Inline authentication is task-specific by design. Even with identical credentials, each inline oauth2(…) or oidc(…) call gets its own isolated OIDC client. This follows the 1:1 DSL semantics: one inline declaration = one isolated client.
Understand when clients are shared
Two call sites share a cached client only when their EndpointKey matches exactly — meaning all of these fields are identical:
-
Authority URL
-
Token endpoint path
-
Client ID and client secret
-
Grant type
-
Scopes and audiences
-
Discovery flag (OAuth2 vs OIDC)
Even a single scope difference creates a separate client:
// ❌ Different scopes = 2 separate clients (not shared)
.authentication("read", a -> a.oauth2(o -> o.scopes("read")))
.authentication("write", a -> a.oauth2(o -> o.scopes("write")))
// ✅ Same scopes = 1 shared client (if using named policy)
.authentication("api", a -> a.oauth2(o -> o.scopes("read", "write")))
Why tokens aren’t cached
Quarkus Flow requests a new token from the authorization server on every authenticated call. Tokens are never cached.
Why:
-
Simplicity — No expiry tracking, no refresh logic, no clock skew handling
-
Security — Expired tokens never used, revoked tokens detected immediately
-
Correctness — Dynamic grant parameters (PASSWORD, TOKEN_EXCHANGE) resolved fresh per request
What IS cached: The OidcClient itself (HTTP connection pool, TLS config, resolved configuration). Only token negotiation happens per request.
If token caching is critical for your use case, configure a pre-authenticated HTTP client and pass tokens via request decorators instead.
Configuration
YAML/JSON workflows for build-time optimization
For workflows without runtime expressions, use YAML or JSON files in src/main/resources to enable build-time OIDC client configuration:
-
Build-time generation — OIDC client config generated as
quarkus.oidc-client.*properties -
Startup creation — Clients created by
quarkus-oidc-clientextension at startup -
Managed lifecycle — Quarkus handles creation, shutdown, and background operations
Place workflows in src/main/resources/flow/ or any classpath location:
# src/main/resources/flow/orders-workflow.yaml
document:
dsl: '1.0.0-alpha1'
name: orders
use:
authentications:
inventory:
oauth2:
authority: https://auth.example.com
grant: client_credentials
# No expressions = build-time config generation
Use routing for environment-specific overrides
Override authentication configuration per environment without changing workflow code:
# Dev environment
%dev.quarkus.flow.oidc.client.payment-api.name=payment-dev
# Prod environment
%prod.quarkus.flow.oidc.client.payment-api.name=payment-prod
Timeout configuration strategy
Configure timeouts at the appropriate level using the 8-level cascade:
# Global default for all workflows
quarkus.flow.oidc.creation-timeout=10s
quarkus.flow.oidc.connection-timeout=10s
# Override for a specific workflow
quarkus.flow.oidc.client.slow-workflow.creation-timeout=30s
quarkus.flow.oidc.client.slow-workflow.connection-timeout=30s
# Override for a specific task
quarkus.flow.oidc.client."orders.task.payment".connection-timeout=45s
Timeout types:
-
Creation timeout — How long to wait for OIDC client initialization (includes discovery)
-
Connection timeout — How long to wait for token negotiation HTTP requests
Common pitfalls
Inline auth creates isolated clients
⚠️ Pitfall: Using inline oauth2(…) or oidc(…) on multiple tasks with identical credentials still creates separate OIDC clients.
✅ Solution: Use named authentication policies under use(…) to share a single client across tasks.
YAML workflow + manual OIDC client config collision
⚠️ Pitfall: If your YAML workflow defines a policy named myauth and you manually configure quarkus.oidc-client.myauth.*, the behavior depends on Quarkus config priority and may not work as expected.
✅ Solution: Use routing configuration to explicitly override:
# Your pre-configured client
quarkus.oidc-client.myProdClient.auth-server-url=...
# Route the workflow's "myauth" policy to use your client
quarkus.flow.oidc.client.myauth.name=myProdClient
Same clientId, different secrets = different clients
⚠️ Pitfall: Two OAuth2 configurations with the same clientId but different clientSecret values create two separate OIDC clients, not one.
✅ Why: The EndpointKey includes all configuration fields, including clientSecret. Different secrets = different keys = different cached clients.
OIDC vs OAuth2 creates different clients
⚠️ Pitfall: oidc(…) and oauth2(…) for the same authority create two separate clients even if all other fields match.
✅ Why: OIDC enables discovery (discovery-enabled=true), OAuth2 disables it (discovery-enabled=false). Different discovery flags = different EndpointKey.
Expressions prevent build-time optimization
⚠️ Pitfall: Using ${ $secret.xxx } expressions in a YAML workflow prevents build-time client configuration generation.
✅ Expected: Policies with expressions are registered lazily at runtime after expression resolution, not at build-time. This is by design — the values aren’t known until runtime.
Troubleshooting
Enable debug logging
See which OIDC clients are created and when:
quarkus.log.category."io.quarkiverse.flow.oidc".level=DEBUG
Look for log messages like:
DEBUG [io.qua.flo.oid.reg.OidcClientWorkflowRegistrar] Registering OIDC client 'keycloak' with EndpointKey: ...
DEBUG [io.qua.flo.oid.reg.OidcClientRegistry] Registered OIDC client with name 'keycloak' and endpoint key.
401 Unauthorized errors
Check:
-
Credentials are correct — Verify
clientIdandclientSecretmatch your auth server configuration -
Token endpoint is correct — For OAuth2, verify
.endpoints(e → e.token("/oauth2/token")) -
Grant type is correct —
CLIENT_CREDENTIALS,PASSWORD,TOKEN_EXCHANGE, etc. -
Scopes are valid — Some auth servers require specific scopes
Timeout errors
If token negotiation times out:
-
Increase connection timeout —
quarkus.flow.oidc.connection-timeout=30s -
Increase creation timeout —
quarkus.flow.oidc.creation-timeout=20s -
Check auth server availability — Verify the
authorityURL is reachable -
Check network configuration — Proxies, firewalls, VPNs
OIDC client not found
If you get "OIDC client not found" errors:
-
Check client name — Named policies: use exact policy name. Inline auth: auto-generated as
namespace:name:version.task.taskName -
Check routing config — If using
quarkus.flow.oidc.client.<key>.name, verify the target client exists -
Check logs — Look for "Registering OIDC client" messages to see what was created
What’s next
-
Configure the HTTP client — timeouts, TLS, and proxies for the downstream calls.