Outbound: OAuth

This chapter covers the case where the 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 the application acquires 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 the application 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.

The first is 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.

The second is 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.

The third is a call to a downstream service on a user's behalf, 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 is the type that mints tokens. The configuration:

use axess::workload::outbound::{OutboundOAuthClient, OutboundOAuthConfig};

let client = OutboundOAuthClient::new(OutboundOAuthConfig {
    token_endpoint: "https://idp.example.com/oauth/token".parse().unwrap(),
    client_id: "billing-api-prod".into(),
    client_credential: ClientCredential::Secret("...".into()),
    scopes: vec!["https://api.downstream.example/.default".into()],
    audience: Some("https://api.downstream.example".into()),
});

token_endpoint is the OAuth server's token endpoint. The endpoint typically comes from the OAuth server's discovery document; the configuration is the resolved URL.

client_credential carries how the application authenticates to the token endpoint. The variants are:

pub enum ClientCredential {
    Secret(ZeroizedString),
    JwtAssertion { signing_key: SigningKey, kid: String },
    Mtls,                         // client cert from outbound TLS
    SignedJwt { /* ... */ },
}

The Secret variant is the classic OAuth client secret. The JwtAssertion variant is RFC 7523 (private_key_jwt authentication), which is what FAPI-grade integrations use; the application signs a short-lived assertion JWT with its private key, and the token endpoint validates it against the registered public key. The Mtls variant uses the outbound TLS connection's client certificate as the authentication. The SignedJwt variant covers cases where the JWT structure differs from RFC 7523.

scopes is the list of scopes requested. The narrowest possible list is the recommendation; over-broad scopes leak privilege if the resulting token is compromised.

audience is the optional audience parameter, used by some token endpoints (Azure AD, Auth0, others that follow the same pattern) to bind the resulting token to a specific resource.

Minting tokens

The simple shape calls mint_token directly:

async fn call_downstream(
    client: &OutboundOAuthClient,
) -> Result<(), Error> {
    let token = client.mint_token().await?;

    let response = http_client
        .get("https://api.downstream.example/data")
        .header("Authorization", format!("Bearer {}", token.access_token))
        .send()
        .await?;
    Ok(())
}

Each mint_token call hits the token endpoint, exchanges the client credentials, and returns the access token. The cost is one round-trip per call.

The optimised shape caches the token for the duration of its validity:

async fn call_downstream_cached(
    client: &CachedOutboundOAuthClient,
) -> Result<(), Error> {
    let token = client.get_cached().await?;
    // token is fresh or freshly-minted; cache handles the expiry.
    // ... use it
}

CachedOutboundOAuthClient is the cache wrapper. The cache uses the same ClockTtlCache machinery the rest of axess uses; the TTL is the token's expires_in value, minus a small buffer so a token that expires mid-call is refreshed proactively.

The right shape depends on the call rate. Below a few calls per minute, the simple shape works. Above that, the cache is worth the complexity.

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::workload::outbound::{TokenExchanger, ExchangeRequest};

let exchanger = TokenExchanger::new(/* ... */);

let token = exchanger.exchange(ExchangeRequest {
    subject_token: inbound_token,
    subject_token_type: "urn:ietf:params:oauth:token-type:jwt".into(),
    audience: "https://api.downstream.example".into(),
    scopes: vec!["read:data".into()],
}).await?;

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 token for the requested audience. The pattern is what most enterprise IdPs support today (Azure AD, Okta, Auth0); the OBO chapter covers it in detail from the application's side.

DPoP and sender-constrained tokens

The FAPI 2.0 chapter (FAPI 2.0) covers DPoP as a way to bind access tokens to a key the client controls. The outbound-oauth machinery supports DPoP through an opt-in configuration:

let config = OutboundOAuthConfig {
    // ... standard configuration ...
    sender_constraint: Some(SenderConstraint::DPoP {
        key_provider: Box::new(my_dpop_key_provider()),
    }),
};

When sender_constraint is set, the client generates a DPoP proof on each call, signed with the configured key, and attaches it to the request along with the access token. The downstream validates the proof, matches the key thumbprint against the token's binding, and serves the request.

The cost is one extra HTTP header per call plus a signature. The benefit is that a stolen access token is unusable without the DPoP key, which the client never transmits.

Threat model

The outbound OAuth flows have a smaller threat surface than the inbound flows because the application controls 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 the application needs, 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 Secret against an endpoint that requires JwtAssertion, or vice versa.

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 the apparently-fresh token, the audience does not match the downstream's expected audience. Some IdPs default the audience to the client id rather than to a resource URL; set the audience parameter explicitly.

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.