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

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

Start

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

Authorize

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

Callback

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

Exchange

Control mints a capability and calls the gateway. Control never sees a token.
5

Persist

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

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

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.