--- title: "OAuth" description: "Start an OAuth connection, hand the operator to the vendor, and consume the vendor redirect. Token exchange and refresh are gateway-owned." --- Control never holds a vendor access token in plaintext. It issues the authorize URL, consumes the vendor's redirect, and asks the gateway to perform the code exchange; the ciphertext comes back sealed and control stores the envelope (ADR-0004, ADR-0023). Background refresh runs in a control worker that calls the same gateway endpoint — there is no HTTP refresh route on this API. Connection lifecycle endpoints live in the catalog router and are documented in full on [/api-reference/catalog](/api-reference/catalog). This page covers the OAuth path through them and the one endpoint the OAuth router owns. ## Authentication Every `/api/v1` route requires a dashboard session token — see [/api-reference/overview](/api-reference/overview) for how it is verified: ```http Authorization: Bearer ``` Responses under `/api/v1` carry `Cache-Control: no-store`. A caller that is not a member of the workspace gets `404`, never `403` — an "exists but forbidden" answer would be an oracle for workspace ids across tenants. A member whose role does not grant the action gets `403 action not permitted`; a frozen workspace answers `409 workspace is frozen` on any non-read action. `GET /oauth/callback` is the exception: it is unauthenticated because the vendor redirects a browser to it. It is bound instead to a single-use, hashed OAuth state row and to the operator who initiated the connection. ## Start an OAuth connection `POST /api/v1/workspaces/{workspace_id}/connections` Requires the `connection.manage` action (admin or owner). Creates a pending connection for a native OAuth catalog entry and, once the workspace snapshot is published, returns an authorize URL for the operator's browser. Workspace the connection belongs to. Catalog slug, for example `github`. Catalog lane. Use `native` for the gateway-owned OAuth path. `nango` and `composio` take the federated path instead — see [/api-reference/catalog](/api-reference/catalog). Your own OAuth client id. No pooled Obol OAuth app exists: a connection uses the customer's own client (invariant 8). Falls back to the deployment's configured client id; when neither is present the request is refused with `400 oauth client id is required`. Your OAuth client secret. Sent to the gateway to be sealed, then stored only as ciphertext on the connection's auth profile. Never echoed back. ### Response Every connection mutation answers with the same envelope. Always `1`. The `operator_connection` projection: `connection_id`, `workspace_id`, `connector_slug`, `connector_display_name`, `source`, `tier`, `status`, `custody`, `evidence_ceiling`, `last4`, `expires_at`, `last_refreshed_at`, `refreshable`, `credential_revision`, `pending_catalog_session`, and `publication`. No `broker_ref`, `target`, or `auth_profile` ever leaves here. What the caller must do next. See [The next object](#the-next-object). ```bash Request curl -X POST https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/connections \ -H "Authorization: Bearer $OBOL_SESSION" \ -H "Content-Type: application/json" \ -d '{ "slug": "github", "source": "native", "client_id": "Iv1.test", "client_secret": "oauth_app_secret" }' ``` ```json Response { "schema_version": 1, "connection": { "schema_version": 1, "connection_id": "conn_9f2c1d", "workspace_id": "ws_acme_prod", "connector_slug": "github", "connector_display_name": "GitHub (User OAuth)", "source": "native", "tier": "native", "status": "pending", "custody": "obol", "evidence_ceiling": "gateway_observed", "last4": null, "expires_at": null, "last_refreshed_at": null, "refreshable": true, "credential_revision": 1, "pending_catalog_session": null, "publication": { "state": "published", "requested_version": 4, "published_version": 4 } }, "next": { "kind": "redirect", "url": "https://github.com/login/oauth/authorize?client_id=Iv1.test&redirect_uri=https%3A%2F%2Fcontrol.tryobol.dev%2Foauth%2Fcallback&scope=repo&state=...&code_challenge=...&code_challenge_method=S256&response_type=code", "expires_at": "2026-08-31T12:10:00+00:00" } } ``` ### The next object One of `none`, `redirect`, `catalog_redirect`, or `restricted_key`. | `kind` | When | Extra fields | | --- | --- | --- | | `redirect` | Native OAuth. Send the operator's browser to the vendor. | `url`, `expires_at` | | `catalog_redirect` | Federated lane. Send the operator to the broker's hosted connect session. | `url`, `expires_at`, `catalog_session_id` | | `restricted_key` | Native API-key profile. Collect a restricted key and post it to `/restricted-key`. | none | | `none` | Nothing to do — usually because the workspace snapshot has not published yet. Retry the read. | none | The authorize URL is built from the connection's stored OAuth profile plus a fresh PKCE challenge: `client_id`, `redirect_uri`, `scope`, `state`, `code_challenge`, `code_challenge_method=S256`, and `response_type=code`. The `state` value is returned only inside `next.url` and is never a separate field in the response body. Its row stores only the SHA-256 hash, and it expires ten minutes after issue. {/* TODO: the native OAuth profile currently hardcodes the `repo` scope in `native_oauth_authorize_url`; confirm whether per-connector scopes ship before this page describes scope as configurable. */} ## Complete the vendor redirect `GET /oauth/callback` The redirect target the vendor sends the operator's browser back to. It is mounted at the root of the control app, not under `/api/v1`, and takes no `Authorization` header. Authorization code from the vendor. Absent on a denied or failed authorization. The opaque state issued in the authorize URL. Single use. Vendor error code. When present, the code is ignored. ### Behavior The state row is consumed first and committed immediately, so a replayed callback finds nothing to consume. The callback then re-derives its authority from the row: the operator who started the connection must still exist, still be a member, and still hold `connection.manage`, and the connection must still be ready for OAuth. The exchange itself is a gateway call; control receives a sealed envelope and writes it under a fresh workspace lock, records a `connection.credential.store` audit event, and stages a snapshot publication. Only two outcomes exist: - `400 invalid or expired oauth state` when `state` is missing, malformed, unknown, expired, or already consumed. This one message covers all of them deliberately. - `302` to the dashboard for everything else. The redirect target is `{OBOL_WEB_URL}/dashboard/connectors` with two query parameters: ```text ?connection_id=conn_9f2c1d&status=connected ``` `connected` when the credential is stored and the workspace snapshot published, `pending` when it is stored but publication has not settled, and `error` for a vendor error, a missing or malformed code, a failed exchange, or an authorization that no longer holds. `error` is intentionally undifferentiated — the callback is a browser destination, not a diagnostic surface. Use [the audit log](/observability/audit-log) to see whether a `connection.credential.store` event was recorded. ## Re-authorize or rotate `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/reconnect` Requires `connection.manage`, and the connection must be `pending`. With an empty body on a native OAuth connection, this bumps `credential_revision`, deletes any outstanding OAuth state rows for the connection, and issues a fresh `redirect`. With `client_secret`, it seals a replacement OAuth client secret first, then issues the redirect. Full parameters, including the federated and API-key modes, are on [/api-reference/catalog](/api-reference/catalog#reconnect-a-connection). ```bash Reconnect curl -X POST https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/connections/conn_9f2c1d/reconnect \ -H "Authorization: Bearer $OBOL_SESSION" \ -H "Content-Type: application/json" \ -d '{}' ``` ## Revoke access There is no OAuth revoke endpoint. Two connection endpoints cover the cases: - `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/disable` stops the connection serving traffic and leaves the credential sealed, so reconnecting restores it. - `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/purge-credential` destroys the sealed access and refresh envelopes, clears `last4` and `expires_at`, bumps `credential_revision`, and leaves the row as the audit record of a credential the workspace once held. Irreversible. Both are documented on [/api-reference/catalog](/api-reference/catalog). ## Token refresh Refresh has no HTTP surface. A scheduled control worker (`refresh_expiring_credentials`) claims connections whose credential is nearing expiry under a row lock, calls the gateway's `/internal/v1/credentials/refresh`, writes the returned envelope, and republishes the workspace snapshot. `connection.refreshable` in the projection is `true` exactly when the connection's auth profile type is `oauth2`. See [/security/oauth](/security/oauth) for the credential lifecycle and [/security/credential-brokering](/security/credential-brokering) for how the sealed envelope reaches an outbound call.