Outbound: OAuth
Your application authenticates itself as a workload against a downstream OAuth-protected service. The application is the OAuth client; the downstream is the resource server. The credential is an access token you acquire through one of the OAuth client flows (client credentials, token exchange, or refresh of a stored token).
The chapter pairs with Inbound: federation and Cloud STS exchange: those cover the inbound case where you accepts workload tokens; this covers the outbound case where the application presents them.
The feature flag is outbound-oauth (off by default).
When to use it
Three patterns lead to outbound OAuth.
A service-to-service call between two services your
deployment owns, where the receiving service authenticates
inbound OAuth (typically through the generic WorkloadResolver
from Inbound: federation). The application's outbound
configuration mints a fresh token through the client-credentials
grant, sends it on the request, and the receiving service
validates it.
A call to a SaaS service that requires OAuth (Slack, Stripe, Twilio, an enterprise CRM). The application is registered as an OAuth client at the SaaS, holds a client id and secret, and mints tokens to call the SaaS's API.
A call on a user's behalf to a downstream service,
where the credential is a token exchanged from the user's session
or from a stored refresh token. This is the OBO case, covered in
Delegated and OBO access; the outbound-oauth machinery in this
chapter is what delegated-stored and delegated-exchange use
under the hood.
Configuration
OutboundOAuthClient fetches tokens through the client-credentials
grant. The configuration:
use axess_core::ZeroizedString;
use axess_core::workload::outbound::oauth_client::{
ClientAuthMethod, OutboundOAuthClient,
};
let client = OutboundOAuthClient::new(
"https://idp.example.com/oauth/token".parse()?,
ClientAuthMethod::ClientSecretBasic {
client_id: "billing-api-prod".into(),
client_secret: ZeroizedString::new(secret),
},
)
.with_scopes(["https://api.downstream.example/.default"]);
The first argument is the OAuth server's token endpoint. It typically comes from the server's discovery document; the configuration is the resolved URL.
The client authenticates to that endpoint with a ClientAuthMethod:
pub enum ClientAuthMethod {
ClientSecretBasic { client_id: String, client_secret: ZeroizedString },
ClientSecretPost { client_id: String, client_secret: ZeroizedString },
PrivateKeyJwt {
client_id: String,
signing_key: EncodingKey,
algorithm: Algorithm,
key_id: Option<String>,
audience: String,
assertion_ttl: Duration,
},
}
ClientSecretBasic puts client_id and client_secret in an
Authorization: Basic header, which is what most off-the-shelf IdPs
expect (Okta, Auth0, Entra). ClientSecretPost sends the same pair as
form fields, which some older IdPs require instead; check their
documentation rather than guessing. Both secrets are zeroized on drop.
PrivateKeyJwt is RFC 7523, and it is the one to reach for in a
FAPI-grade integration: axess signs a short-lived assertion with its
private key and the IdP validates it against the published JWKS, so
there is no shared secret to rotate. Its audience is the assertion
JWT's own aud claim: per RFC 7523 §3 a value the IdP recognises as
naming itself, usually the token endpoint URL, though some IdPs want
their issuer URL instead. It does not name the downstream API. The
signing_key is validated when the client is constructed, so a
malformed key fails at startup rather than on the first call.
mTLS is not a variant here. Outbound client-certificate authentication is a property of the connection rather than of the token request; see Outbound mTLS.
The rest of the builder is small: with_scopes sets the requested
scopes, with_refresh_threshold moves the cache's refresh point
(default 30 seconds before expiry), and with_clock, with_rng and
with_http_client substitute the ambient dependencies, the first two
for deterministic tests, the third for proxy, timeout or outbound-mTLS
configuration.
The narrowest possible scope list is the recommendation; over-broad scopes leak privilege if the resulting token is compromised.
There is no audience parameter. Token endpoints that bind a token to a
specific resource through a non-standard audience form field (Auth0,
some Azure AD configurations) are not covered by the builder; where the
IdP accepts a resource-shaped scope instead (.default for Entra, for
example), express it through with_scopes.
Getting a token
get_access_token returns the current token as a String, fetching
one if the cache has nothing fresh:
async fn call_downstream(
client: &OutboundOAuthClient,
http: &reqwest::Client,
) -> Result<(), Error> {
let token = client.get_access_token().await?;
let response = http
.get("https://api.downstream.example/data")
.bearer_auth(&token)
.send()
.await?;
Ok(())
}
OutboundOAuthClient always caches, and there is no wrapper to reach
for: it holds the token itself. The first call
fetches and stores the response under a write lock, later calls inside
the validity window read it under a read lock, and the window ends
refresh_threshold before expires_in so a token does not expire
mid-flight. Call it per request rather than holding the returned
String, and the refresh is handled for you.
force_refresh bypasses the cache and replaces its contents, which is
what to call when the downstream rejects an apparently-fresh token,
since the IdP may have revoked it early.
Token exchange (RFC 8693)
The token-exchange flow is the alternative to client-credentials when the outbound call is on behalf of an inbound principal (human or workload). The application presents the inbound credential to a token-exchange-capable IdP and receives a token bound to the downstream audience.
use axess_core::ZeroizedString;
use axess_core::delegated::exchange::{TokenExchangeClient, TokenExchangeRequest};
let client = TokenExchangeClient::new(
token_endpoint,
"billing-api-prod",
Some(ZeroizedString::new(client_secret)),
);
let token = client
.exchange(
&TokenExchangeRequest::new(
inbound_token,
"urn:ietf:params:oauth:token-type:jwt",
)
.with_audience("https://api.downstream.example")
.with_scopes(["read:data"]),
)
.await?;
The client secret is optional. Pass None where the authorization
server authenticates axess through mTLS at the transport layer
instead, and configure the certificate on a reqwest::Client handed
to with_http_client.
The exchange runs through the IdP's token endpoint with the
RFC 8693 parameters; the IdP validates the subject token,
applies whatever exchange policy it has, and returns a
TokenExchangeResponse. Its access_token is a ZeroizedString
rather than a String, so the in-memory copy zeroes on drop; deref it
where the HTTP client wants a &str. The pattern is what most
enterprise IdPs support today (Azure AD, Okta, Auth0); the OBO chapter
covers it in detail from your side.
Sender-constrained tokens
The FAPI 2.0 chapter (FAPI 2.0) covers DPoP and mTLS as ways to bind
an access token to a key the client controls. That machinery is
inbound: SenderConstraint is a field of FapiConfig, which applies
to an OAuthProviderConfig axess authenticates users against.
The outbound client does not generate DPoP proofs. Sender-constraining
an outbound call means one of two things instead. Either authenticate
to the token endpoint with ClientAuthMethod::PrivateKeyJwt, which
proves possession of a private key on every token request and removes
the shared secret a thief could replay; or present a client
certificate on the connection, which is Outbound mTLS, and ask the
IdP to bind the issued token to that certificate under RFC 8705.
Whether the second is available is the IdP's decision, not axess's:
the binding is recorded in the token's cnf claim by the issuer. Axess
presents the certificate; it does not verify that the issuer acted on
it.
Threat model
The outbound OAuth flows have a smaller threat surface than the inbound flows because you control both ends of the trust relationship.
Against client credential theft: the credential lives in the application's secrets store. Theft requires application-level compromise, which has bigger problems than just the OAuth credential.
Against access token theft in transit: TLS protects the wire. A stolen token from a TLS-protected call requires breaking TLS, which is not the OAuth client's defence to provide.
Against access token theft at rest: tokens are short-lived (typically minutes) and held in process memory. A long-lived refresh token (in the stored OBO case) is what carries longer exposure; the encrypted credential store decorator covers that.
Against scope creep: the scopes parameter restricts what the token can do. The discipline is to request the narrowest scopes you need, so a compromised token has limited blast radius.
Troubleshooting
If the token endpoint returns invalid_client, the client
credentials are not what the IdP expects. The most common cause
is using ClientSecretBasic against an endpoint that wants the
credentials as form fields (ClientSecretPost), or a shared secret
where the IdP expects PrivateKeyJwt.
If the token endpoint returns invalid_scope, the requested
scopes are not authorised for this client. Check the client's
registration at the IdP to see which scopes are permitted.
If the downstream returns 401 on an apparently-fresh token, the
audience does not match what the downstream expects. Some IdPs
default a client-credentials token's audience to the client id
rather than to a resource URL. The builder exposes nothing to
override it, so the fix is at the IdP, either by registering the downstream as
a resource and requesting its scope (.../.default and similar) or
by configuring the default audience on the client registration.
If a call fails on a token that worked moments earlier, the IdP
revoked it before its stated expiry. The cache has no way to learn
this, so call force_refresh on a 401 and retry once before
surfacing the error.
Further reading
OAuth 2.0 and OIDC covers the inbound OAuth machinery and the shared OIDC primitives. FAPI 2.0 covers DPoP and the sender-constrained-token pattern. Delegated and OBO access covers the higher-level OBO machinery that uses outbound OAuth under the hood. Operations runbook covers client-credential rotation and the DPoP key lifecycle.