--- 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).