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 createdpending 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.
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 withiss: 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_capabilityrequires the dedicated audience, pins the issuer, rejects a lifetime of zero or over 300 seconds, rejects a futureiatbeyond 30 seconds of skew, and requires a non-empty nonce of at most 128 characters and a 64-character hexauth_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_revisionmust equalexpected_revision. - The auth profile’s digest must equal the capability’s, and the endpoints are
read from that profile.
bind_urlthen requires the capability’s claim to equal the pinned profile value exactly — a missing claim is rejected asbinding 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, postsgrant_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 workerrefresh_expiring_credentials
(apps/control/app/workers.py) and executed by the gateway.
- The worker selects due connections and leases one by claiming
refresh_started_atunder 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. - It mints an
obol-gateway-cred-refreshcapability bound to the active credential’s revision and profile digest. - 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_tokento the pinned token endpoint, and re-seals. - 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: Opening a connect session is a gateway operation because it spends that sealed broker credential:- Control creates a
cxs_connect-session row bound to the initiating operator, a generatedcatalog_user_id, the reviewed MCP origins frozen at handoff time, and a 10-minute expiry. - Control mints an
obol-gateway-catalog-sessioncapability naming the broker connection and the session URL, and callsPOST /internal/v1/catalog/session. - The gateway requires federated custody and a
federated_brokerprofile, binds and guards the session URL, unwraps the broker credential, and posts the provider-specific body (Nangoend_user/allowed_integrations; Composiouser_id/auth_config_id). - The gateway screens the response for asserted authority before parsing
it. A body carrying
target,policy,evidence,receipt,idempotency,idempotency_key,retry, orcredentialat any depth is refused outright (ADR-0038). Exactly one field is read out: the redirect the operator is sent to.
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.