Delegated and OBO access

The scenario is common: your application needs to act on behalf of the user against a downstream service. A user signs in, grants your application the right to read their inbox or post on their behalf, and from that moment forward your application can make calls to the downstream service that the downstream sees as coming from the user. The mechanism is on-behalf-of (OBO) access, and axess covers two shapes through the delegated/ module under axess-core.

The feature flag is delegated (off by default), with two narrower variants (delegated-stored, delegated-exchange) that turn on each shape independently. The module lives inside axess-core rather than as a separate crate because the encryption envelope it needs already ships with the SQL session backends, so the isolation benefit a separate crate would have provided was illusory. Adopters who do not turn on the feature pay zero compile cost.

The two shapes

OBO comes in two architectural shapes. The shape matters because the operational characteristics differ: where credentials live, how often they refresh, what happens when the user revokes consent.

The first shape is stored OBO. The user grants consent once through an OAuth flow; you receive a refresh token along with the initial access token; you persist the refresh token; future calls to the downstream service use the refresh token to mint a fresh access token, then use the access token to make the actual call. The pattern is what most "connect your Google account" or "connect your Slack account" flows do.

The second shape is token exchange (RFC 8693). The user's session in your application carries a credential (a session cookie, a JWT session, a workload identity token). When you need to call a downstream service on the user's behalf, it presents the credential to a Security Token Service (STS) and receives a short-lived access token bound to the call. There is no persistent storage of credentials for the downstream; the exchange happens per call (or per a short cache window).

The two shapes solve different problems. Stored OBO is right when you need to act on the user's behalf when the user is not actively present (a scheduled report that pulls from Gmail at 6am, a background sync that runs while the user is offline). Token exchange is right when you need to act on the user's behalf only while the user has an active session, and where the user's session credential can be exchanged for a downstream credential at low cost.

The two mechanisms

One keeps a refresh token on your side; the other keeps nothing.

Stored OBO

The stored OBO shape uses the delegated-stored feature. The machinery has three moving parts: an OAuth flow that grants initial consent, a credential store that persists the refresh token, and a refresh path that mints fresh access tokens for calls.

The initial grant is an OAuth authorization code flow where the scopes include the downstream's access scope (https://mail.google.com/, channels:read, whatever the downstream's vocabulary is) and the flow includes offline_access (the OAuth scope that asks for a refresh token). The flow's success returns both an access token (usable immediately) and a refresh token (storable for later use).

The persistence runs through the DelegatedCredentialStore trait, keyed by the (tenant, user, provider) triple:

use axess_core::delegated::stored::{DelegatedCredentialStore, StoredDelegation};

pub trait DelegatedCredentialStore: Send + Sync + 'static {
    fn load(
        &self,
        tenant: &TenantId,
        user: &UserId,
        provider: &str,
    ) -> impl Future<Output = Result<Option<StoredDelegation>, String>> + Send;

    fn save(
        &self,
        tenant: &TenantId,
        user: &UserId,
        credential: StoredDelegation,
    ) -> impl Future<Output = Result<(), String>> + Send;

    fn revoke(
        &self,
        tenant: &TenantId,
        user: &UserId,
        provider: &str,
    ) -> impl Future<Output = Result<(), String>> + Send;
}

pub struct StoredDelegation {
    pub provider: String,
    pub access_token: ZeroizedString,
    pub refresh_token: Option<ZeroizedString>,
    pub expires_at: Option<DateTime<Utc>>,
    pub scopes: Vec<String>,
    pub token_type: String,
}

Two details in that signature are worth reading twice. The futures are native impl Future rather than #[async_trait] boxes, so an implementation costs no allocation per call. And the error is a plain String: the trait does not constrain the implementor's concrete error type, which keeps a store backed by sqlx, by Dynamo, or by a bespoke vault from having to convert into an axess error enum. Format the detail you want to see in your logs into that string.

There is no owner type. The triple is passed positionally, so the same (tenant, user) pair holds independent credentials for "google", "slack" and "github" side by side, and provider matches the DelegatedProvider::name of the grant it came from.

save overwrites. Refresh-token rotation lands there rather than on a separate update path, because the store treats the whole credential as the unit of write.

Both token fields are ZeroizedString, so the in-memory copy zeroes on drop. That says nothing about storage: encryption at rest is the implementor's responsibility, and the plaintext reaches your save unless you wrap the store. MemoryDelegatedCredentialStore ships for dev and test and holds plaintext; it is not a production store.

The wrapper is EncryptedDelegatedCredentialStore<S, K>, a decorator over any store S and a KeyProvider K. It encrypts with AES-256-GCM under a random 12-byte nonce per row, and the key provider carries historical keys so a rotation can still read what the previous key wrote. The trait surface is unchanged; the encryption happens inside the decorator.

The refresh path is StoredDelegationSession. It wraps a store and a provider, and its get_access_token loads the credential, returns the access token if it is still fresh against the configured skew, and otherwise runs the refresh exchange and saves the result before returning. Do not read StoredDelegation::access_token directly; the session is what owns refresh-before-expiry. StoredDelegationSession::revoke is the matching teardown: it calls the provider's revocation endpoint where one is configured and removes the stored row.

Token exchange

The token exchange shape uses the delegated-exchange feature. The machinery is much smaller because there is no persistent storage: the exchange runs per call.

The exchange is an RFC 8693 token exchange. The application presents:

  • A subject token: the credential identifying the user. This might be the user's session ID, a JWT session token, or a workload identity token that names the user.
  • A subject token type: an identifier for the kind of subject token (urn:ietf:params:oauth:token-type:access_token, urn:ietf:params:oauth:token-type:jwt, an application-specific string).
  • The audience: the downstream service the token will be used against.
  • Optional: the scope of the requested token (defaults to "all scopes the user has").

The STS validates the subject token, determines the user's identity, applies whatever policy decisions the deployment has configured (Cedar policies that govern the exchange, the user's allowed downstreams), and returns an access token bound to the audience.

use axess_core::ZeroizedString;
use axess_core::delegated::exchange::{TokenExchangeClient, TokenExchangeRequest};

let client = TokenExchangeClient::new(
    token_endpoint,
    client_id,
    Some(ZeroizedString::new(client_secret)),
);

let downstream_token = client
    .exchange(
        &TokenExchangeRequest::new(session_credential, ACCESS_TOKEN_TYPE)
            .with_audience("https://api.downstream.example")
            .with_scopes(["read:data"]),
    )
    .await?;

let response = http_client
    .get("https://api.downstream.example/data")
    .bearer_auth(&*downstream_token.access_token)
    .send()
    .await?;

The exchange runs in the request path. The latency cost is one round-trip to the STS plus the actual downstream call. The exchanged token is short-lived (typically minutes), so the application either re-exchanges per call (the simple shape) or caches the exchanged token for the duration of its validity (the optimisation, which is worth the complexity only at high call rates).

Which to use

The decision tree is short.

If you need to act on the user's behalf while the user is offline (a background job, a scheduled report, a notification that runs hours after the user has gone home), use stored OBO. Token exchange does not work because the user's session does not exist when the call needs to happen.

If you call the downstream only while the user is actively signed in, and the downstream service supports token exchange (Azure AD, Google Cloud, most enterprise SaaS that supports RFC 8693), use token exchange. The credential never hits your database, so the breach impact is smaller.

If you need both shapes, both work side by side. The two crates compose without conflict; turn on both feature flags.

The most common shape in practice is hybrid: token exchange for the foreground synchronous calls (the user clicks "fetch latest data from Gmail"), stored OBO for the background asynchronous calls (the nightly sync that pulls all new mail since the last run). The two flows handle the two needs.

Operating it

Recording consent, and taking it back.

Both shapes need an audit trail. The user granted consent at a specific moment; that moment is what defends against later disputes ("this application made calls I did not authorise").

Axess emits neither. Both shapes are primitives you call directly: complete_grant returns a StoredDelegation for you to store, and TokenExchangeClient::exchange returns a response. Neither has an audit sink on its call path, and the event vocabulary has no name reserved for a consent grant, a credential use or a token exchange.

So the trail is yours to write, and it is worth writing. Record the grant with what the user agreed to (which scopes, which downstream) and each use with when, against which downstream, and for which operation if you surface that. Write it where you perform the operation, into the same store your IdentityAuthnLog implementation writes to, so the delegated trail and the authentication trail line up.

The audit retention for delegated events is typically longer than for ordinary authentication events because the events defend against future disputes that may surface months or years later. The retention configuration is in Audit pipeline.

Revocation

Both shapes need a revocation path. The user (or an administrator) decides you should no longer act on their behalf; the next call should fail.

Stored OBO revocation runs through DelegatedCredentialStore::revoke, which StoredDelegationSession::revoke calls for you after it has told the provider. The credential is removed from the store (or marked revoked, if the store retains for audit). Subsequent loads return None; your call path either treats this as "user has not granted consent" or as "consent was revoked, ask again."

Token exchange revocation runs through the user's session revocation. Logging the user out invalidates the session credential, which means subsequent exchanges fail; in-flight calls that have already exchanged the token continue until the exchanged token expires (typically minutes). The granularity is coarser than stored OBO but the operational simplicity is the trade-off.

Either shape benefits from the downstream's own revocation mechanism. Most OAuth providers support RFC 7009 token revocation; calling it on logout invalidates the access and refresh tokens at the IdP, so even a stolen credential cannot be used. Stored OBO with downstream revocation gives the strongest possible revocation guarantee.

Threat model

The threat surface for OBO is unusual. The application acts as the user, which means a compromise of your application is a compromise of the user's downstream account. The defences:

Minimise the scope of the OAuth grant. Request the narrowest scopes you need (channels:read not channels:*, the specific calendar not "all calendars"). The an attacker who compromises you can act only within the granted scopes.

Encrypt the stored credentials at rest. The EncryptedDelegatedCredentialStore decorator covers this. An attacker who breaches the database without the encryption key cannot use the stored credentials.

Monitor the trail you wrote above. A spike in delegated-credential use for one user, especially against operations they do not normally perform, is a strong signal of compromise. Axess supplies no rule for this because it supplies no event; the shape of the query follows whatever you recorded.

The fourth is to time-bound consent. Some downstreams support explicit consent expiry; for those that do not, you can require the user to re-consent on a schedule (every ninety days, every year). The friction is real; the defence against long-lived stale grants is also real.

The applications this opens up

OBO is what lets axess fit into the kind of application that does more than authenticate users for itself: a unified inbox that pulls from Gmail and Outlook, a CI pipeline that posts to Slack on the user's behalf, a calendar integration that books meetings. The mechanism is opt-in (the feature flag), the two shapes cover the architectural choices, and the encryption-at-rest plus the audit trail let the deployment defend its decisions.

Further reading

Refresh tokens and session continuity covers the refresh-token family-detection mechanism that also applies to stored OBO credentials. OAuth 2.0 and OIDC covers the OAuth flow that grants the initial consent. Workload identity overview covers the subject-token side of token exchange when the subject is a workload rather than a human. Audit pipeline covers the sink the delegated trail you write should share with the authentication trail.