---
title: "Customer OAuth connections"
description: "Control orchestrates OAuth consent and state; the gateway performs every operation in which a customer's OAuth credential is plaintext."
---
Connecting a vendor with OAuth splits along one line: **control owns consent,
connection lifecycle, scheduling, and Postgres; the gateway owns every operation
in which an Obol-custodied OAuth credential is plaintext** (ADR-0023). A control
callback that exchanged an authorization code, or a control worker that
submitted a refresh token, would hold a plaintext vendor token and so would
violate ADR-0004's custody boundary even if it encrypted the result a
microsecond later.
The gateway gains no database pool and no OAuth product surface from this. It
gains four internal endpoints.
## The two custody modes
| Custody | Who holds the vendor token | OAuth lifecycle |
|---|---|---|
| `obol` | Obol's vault, sealed by the gateway | Exchange and refresh run on the gateway |
| `federated` | The catalog broker (Nango or Composio) | Stays at the federated upstream; Obol holds only a workspace-scoped broker reference |
The gateway refuses to mix them. `reject_federated_oauth` fails an exchange or
refresh on any connection whose custody is `federated`, with
`federated catalog connections keep oauth at the upstream`. Sealing a vendor
secret against a federated connection is refused for the same reason.
## Native OAuth connection
A native OAuth connection is created `pending` with an `oauth2` auth profile
carrying `authorization_endpoint`, `token_endpoint`, `redirect_uri`,
`client_id`, `client_auth`, and `scopes`
(`apps/control/app/services/connections.py`). The workspace supplies its own
`client_id`; without one, `POST /workspaces/{workspace_id}/connections` fails
with `oauth client id is required`. An optional `client_secret` is sealed by the
gateway and stored as an envelope inside the auth profile — control never holds
it in plaintext.
`POST /workspaces/{workspace_id}/connections` creates the pending connection,
stages a snapshot publication, and issues the authorize redirect. The connection
snapshot must be published before state is issued, or the call answers 409.
`issue_native_oauth_state` persists a one-use state row bound to the initiating
operator: `sha256(state)`, a PKCE verifier, a 10-minute expiry, and any prior
state row for that connection deleted. The authorize URL carries
`code_challenge` with `code_challenge_method=S256`.
`GET /oauth/callback` (`apps/control/app/api/oauth.py`) validates and consumes
the state row — reading it `FOR UPDATE`, clearing the verifier, and deleting the
row — then re-resolves the initiating operator, their membership, and the
`connection.manage` action before doing anything else.
Control mints a capability and calls the gateway. Control never sees a token.
Control writes the returned envelope under a compare-and-swap, records an audit
row, and publishes a new snapshot.
Every failure path redirects to the dashboard with `status=error` and a
connection id. No vendor error text, submitted code, or state value is
reflected.
## Capabilities: how control asks the gateway to act
Control cannot hand the gateway a token endpoint, a redirect URI, a client
secret, or credential bytes. It mints a short-lived, single-use **credential
capability** — an HS256 JWT with `iss: obol-control` and a dedicated audience
per operation (`apps/control/app/core/security.py`):
| Audience | Endpoint | Purpose |
|---|---|---|
| `obol-gateway-cred-exchange` | `POST /internal/v1/credentials/exchange` | Authorization-code exchange |
| `obol-gateway-cred-refresh` | `POST /internal/v1/credentials/refresh` | Token refresh |
| `obol-gateway-cred-seal` | `POST /internal/v1/credentials/seal` | Seal an operator-supplied secret |
| `obol-gateway-catalog-session` | `POST /internal/v1/catalog/session` | Open a broker connect session |
Each capability binds `workspace_id`, `connection_id`, an
`auth_profile_digest`, an `expected_revision`, a random `nonce`, and an expiry.
Exchange additionally binds `token_endpoint` and `redirect_uri`; a catalog
session binds `session_url` and `broker_connection_id`.
The gateway validates all of it independently
(`apps/gateway/crates/obol-gateway/src/routes/credentials.rs`):
- The caller must present a valid service JWT for audience `obol-gateway`.
- `verify_capability` requires the dedicated audience, pins the issuer, rejects a
lifetime of zero or over 300 seconds, rejects a future `iat` beyond 30 seconds
of skew, and requires a non-empty nonce of at most 128 characters and a
64-character hex `auth_profile_digest`.
- The nonce is atomically consumed; a replay answers `replayed capability`.
- The connection is loaded from the **workspace snapshot**, never from control,
and its `credential_revision` must equal `expected_revision`.
- The auth profile's digest must equal the capability's, and the endpoints are
read from that profile. `bind_url` then requires the capability's claim to
equal the pinned profile value exactly — a missing claim is rejected as
`binding required`.
- The resolved endpoint goes through the same egress guard as connector
dispatch. See [/security/egress-and-ssrf](/security/egress-and-ssrf).
Control's signed claims never select an endpoint or a credential by themselves.
They can only agree with what the validated snapshot already says.
## Exchange
With those checks passed, the gateway unwraps the client secret if the profile
uses one, posts `grant_type=authorization_code` with the code, redirect URI,
PKCE verifier, and client id to the pinned token endpoint, then seals the access
token and any refresh token and returns ciphertext, wrapped DEKs, expiry,
scopes, safe metadata, and `credential_revision + 1`.
Control applies that with `require_pending_credential_binding`, which rejects
the write if the connection's workspace, id, `pending` status, revision, or auth
profile digest moved while the gateway was talking to the vendor — answering
`connection changed while credential was sealed` (409). It then stages and
publishes a snapshot.
## Refresh
Refresh is scheduled by control's arq worker `refresh_expiring_credentials`
(`apps/control/app/workers.py`) and executed by the gateway.
1. The worker selects due connections and **leases** one by claiming
`refresh_started_at` under a row lock, re-reading state under the lock so a
winner that already refreshed is not refreshed again. Two concurrent runs
would rotate a rotating refresh token twice and strand the loser.
2. It mints an `obol-gateway-cred-refresh` capability bound to the *active*
credential's revision and profile digest.
3. The gateway consumes the nonce, loads the connection from Redis or the
snapshot with no control fallback, unwraps the refresh token, posts
`grant_type=refresh_token` to the pinned token endpoint, and re-seals.
4. Control applies the rotation under `require_active_credential_binding` — a
stale revision is rejected — and publishes.
`apply_credential_refresh` never writes a status. Activation belongs to the
operator-initiated exchange; a refresh that also set `status = "active"` could
resurrect a connection an operator disabled mid-call.
Because refresh runs on the gateway, gateway availability now affects scheduled
refresh. That is an accepted consequence of the custody boundary, recorded in
ADR-0023.
## Federated connections
A federated connection uses a catalog broker for credential custody. Two rules
from ADR-0030 are enforced in code, not documentation:
**No pooled broker account and no pooled broker OAuth application.** A federated
connection uses the customer's own OAuth client at the broker. The broker API key
is registered per workspace (`POST /workspaces/{workspace_id}/catalog-providers`),
sealed into that workspace's own vault envelope, and applied with the broker's
own scheme — bearer for Nango, `x-api-key` for Composio.
Opening a connect session is a gateway operation because it spends that sealed
broker credential:
1. Control creates a `cxs_` connect-session row bound to the initiating
operator, a generated `catalog_user_id`, the reviewed MCP origins frozen at
handoff time, and a 10-minute expiry.
2. Control mints an `obol-gateway-catalog-session` capability naming the
broker connection and the session URL, and calls
`POST /internal/v1/catalog/session`.
3. The gateway requires federated custody and a `federated_broker` profile,
binds and guards the session URL, unwraps the broker credential, and posts
the provider-specific body (Nango `end_user` / `allowed_integrations`;
Composio `user_id` / `auth_config_id`).
4. The gateway **screens the response for asserted authority** before parsing
it. A body carrying `target`, `policy`, `evidence`, `receipt`,
`idempotency`, `idempotency_key`, `retry`, or `credential` at any depth is
refused outright (ADR-0038). Exactly one field is read out: the redirect the
operator is sent to.
That screen is invariant 9 in code — a broker is a vendor, never an authority.
See [/connectors/federated](/connectors/federated).
### Completion is currently refused
`POST /workspaces/{workspace_id}/connections/{connection_id}/federated-complete`
validates the operator, the session, and the returned MCP URL against the frozen
origins, and then **refuses to activate**:
`complete_federated_session` raises `broker_verification_unavailable` (409).
The reason is stated in the code: the target probe proves a reviewed, reachable
origin, not who owns the connected account, whether authorization is active, or
whose OAuth client it uses. No authenticated broker-account verifier exists yet,
and an operator-supplied identifier cannot be authority for a credentialed route
(ADR-0030, invariants 8 and 9).
{/* TODO: Revisit when an authenticated broker-account verifier ships — this section describes the state of `apps/control/app/services/connections.py` on the current branch. */}
## Revocation is two-step on a federated connection
Revoking at Obol covers the Obol hop only. The broker still holds a live
credential until control calls the broker's disconnect API, and a broker-held
token can act outside Obol entirely — through the customer's own scripts or
another connected agent. Obol's audit log is a record of Obol-mediated traffic,
not of everything a credential did (ADR-0030).
## Destroying a stored credential
`POST /workspaces/{workspace_id}/connections/{connection_id}/purge-credential`
clears every field that is or could reconstruct the sealed secret — ciphertext,
wrapped DEK, KEK provider, KMS key id, scheme, `last4`, the refresh envelope, and
`expires_at` — bumps `credential_revision`, and sets the connection `disabled`.
The row itself survives as the audit trail, with the purged `last4` moved into
the audit detail.
This is irreversible. Obol holds one copy of a vaulted secret and the plaintext
never left the gateway process, so nothing can put it back; the connection
returns to service only by being re-credentialed. `disable` is the reversible
half — it stops a connection serving while leaving its credential sealed.
Route shapes are in [/api-reference/oauth](/api-reference/oauth).