> ## Documentation Index
> Fetch the complete documentation index at: https://docs.airctrl.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth protocol

> Authorization Code, PKCE, token rotation, revocation, callbacks, and error payloads.

AIRCTRL implements OAuth 2.0 Authorization Code for confidential partner backends, PKCE
`S256`, RFC 9207 authorization response issuer identification, rotating refresh tokens, and RFC 7009
token revocation. It is not an OpenID Connect login provider and does not issue ID Tokens or expose
UserInfo.

## Environments and discovery

Always read endpoint URLs and token lifetimes from authorization-server metadata:

| Environment | Discovery URL |
| - | - |
| Production | `https://api.airctrl.dev/.well-known/oauth-authorization-server` |
| QA | `https://api.staging.airctrl.dev/.well-known/oauth-authorization-server` |
| Local | `http://localhost:15430/.well-known/oauth-authorization-server` |

Set `AIRCTRL_API_ORIGIN`, `AIRCTRL_API_URL`, and `AIRCTRL_WEB_URL` from
[Environments](https://docs.airctrl.dev/reference/environments.md). The payloads below use Production values only to make each
example concrete; substitute one complete environment as a unit.

Example response:

```json theme={}
{
  "issuer": "https://api.airctrl.dev",
  "authorization_endpoint": "https://app.airctrl.dev/oauth/authorize",
  "token_endpoint": "https://api.airctrl.dev/v1/oauth/token",
  "revocation_endpoint": "https://api.airctrl.dev/v1/oauth/revoke",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": [
    "protections:provision",
    "protections:read",
    "protections:update",
    "protections:manage",
    "protections:read-logs",
    "protections:read-spend",
    "offline_access"
  ],
  "authorization_response_iss_parameter_supported": true,
  "service_documentation": "https://docs.airctrl.dev/guides/oauth-partner-integrations",
  "airctrl_token_lifetimes": {
    "authorizationCodeSeconds": 300,
    "accessTokenSeconds": 900,
    "refreshTokenSeconds": 7776000
  }
}
```

The values above show production. QA and local metadata return their own issuer and endpoints.

## Start authorization

Generate high-entropy `state`, a PKCE verifier, and its SHA-256 challenge in the confidential backend.
Store `state`, the verifier, redirect URI, pending external-key intent, and expiry in a short-lived
server session.

```http theme={}
GET https://app.airctrl.dev/oauth/authorize
  ?client_id=ac_oauth_example
  &redirect_uri=https%3A%2F%2Fpartner.example.com%2Foauth%2Fcallback
  &response_type=code
  &scope=protections%3Aprovision%20protections%3Aread%20offline_access
  &state={high-entropy-state}
  &code_challenge={base64url-sha256-verifier}
  &code_challenge_method=S256
```

Required parameters:

| Parameter | Requirement |
| - | - |
| `client_id` | Client ID shown by My OAuth Apps. |
| `redirect_uri` | Exact registered HTTPS callback. |
| `response_type` | `code`. |
| `scope` | Space-delimited subset enabled on the App. |
| `state` | High-entropy, single-use partner value; server length 8–1,024 characters. |
| `code_challenge` | Exact 43-character base64url SHA-256 challenge for a 43–128 character verifier. |
| `code_challenge_method` | `S256`. |

For a private App, consent is bound to the App owner's organization. For a public App, consent lists
every organization where the approving person is an active member, holds
`oauth-installations:connect`, and has active product access. When several qualify, the user must choose
one before **Allow** is enabled. Active subscription, trial, and payment-retry (`past_due`) states
qualify.

The authorization code and every resulting token are bound to the selected organization and one
installation. The App owner's organization, other eligible organizations, and other installations are
not fallback targets.

The prepared consent request remains pending for 10 minutes. After approval, the one-time
authorization code remains valid for 300 seconds, as published in `airctrl_token_lifetimes`.

## Handle the callback

Successful callback:

```http theme={}
GET https://partner.example.com/oauth/callback
  ?code=ac_oac_one_time_code
  &state={original-state}
  &iss=https%3A%2F%2Fapi.airctrl.dev
```

Before exchanging the code, require:

* exact `state` match;
* exact `iss` match with discovery metadata;
* non-expired, single-use server session;
* exact redirect URI used at authorization; and
* the original PKCE verifier.

Denied or invalid authorization preparation redirects with the original `state` when a trusted
redirect URI is available:

```http theme={}
GET https://partner.example.com/oauth/callback
  ?error=access_denied
  &error_description=subscription_inactive
  &state={original-state}
  &iss=https%3A%2F%2Fapi.airctrl.dev
```

Do not exchange a callback containing `error`.

The server rechecks permission and product access when the user chooses **Allow access**. If that final
transaction fails before a code is committed, the AIRCTRL authorization page reports the
API error and does not produce a partner callback.

## Exchange the authorization code

Authenticate the confidential client with HTTP Basic. Use a form-encoded body; do not put the client
secret in JSON, query parameters, or browser code.

```http theme={}
POST /v1/oauth/token
Authorization: Basic base64(urlencode(client_id):urlencode(client_secret))
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=ac_oac_one_time_code&
redirect_uri=https%3A%2F%2Fpartner.example.com%2Foauth%2Fcallback&
code_verifier={original-verifier}
```

```json theme={}
{
  "access_token": "sk-actrl-oat-returned-once",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "sk-actrl-ort-returned-once",
  "scope": "protections:provision protections:read offline_access"
}
```

`refresh_token` is present only when `offline_access` was authorized. Store tokens encrypted and bind
them to the OAuth installation/organization, never to a browser user session.

## Rotate a refresh token

Serialize refreshes per installation. Two workers must never use the same refresh token concurrently.

```http theme={}
POST /v1/oauth/token
Authorization: Basic base64(urlencode(client_id):urlencode(client_secret))
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=sk-actrl-ort-current
```

```json theme={}
{
  "access_token": "sk-actrl-oat-next",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "sk-actrl-ort-next",
  "scope": "protections:provision protections:read offline_access"
}
```

Atomically replace the stored refresh token after success. Reusing an older token is replay and revokes
the family. A successful refresh resets the 90-day refresh inactivity window.

## Revoke a token

```http theme={}
POST /v1/oauth/revoke
Authorization: Basic base64(urlencode(client_id):urlencode(client_secret))
Content-Type: application/x-www-form-urlencoded

token=sk-actrl-ort-current
```

Success is HTTP `200` with no response body. Revocation is idempotent; an unknown, already-revoked, or
wrong-type token still returns `200` after valid client authentication. Revoking an access token ends
that token; revoking a refresh token ends its refresh family. This protocol operation does not delete
projects, gateways, provider credentials, logs, or audit history.

## Protocol errors

Authorization errors return through the trusted redirect URI when possible. Token and revocation
errors return JSON:

```json theme={}
{
  "error": "invalid_grant",
  "error_description": "The authorization code or refresh token is invalid, expired, consumed, or replayed."
}
```

| Error | Meaning | Partner action |
| - | - | - |
| `invalid_client` | HTTP Basic client authentication failed. | Stop; repair the Client ID/secret. |
| `invalid_grant` | Code/verifier/redirect failed, or refresh token is expired, consumed, or replayed. | Stop retries and start authorization again. |
| `invalid_scope` | Requested scopes are empty, unknown, or not enabled on the App. | Request only the registered subset. |
| `access_denied` | User denied consent, no eligible organization is available, permission is missing, or the selected subscription is inactive. | Show the safe reason and do not retry unchanged. |
| `oauth_redirect_uri_invalid` | Redirect URI is not an exact registered value. | Correct registration and restart authorization. |
| `oauth_state_invalid` / `oauth_state_mismatch` | State is missing, malformed, or different from the stored transaction. | Abort the transaction and create a new one. |
| `oauth_authorization_request_expired` | The pending consent request expired or was already decided. | Start a new authorization transaction. |
| `oauth_not_configured` | The authorization server is unavailable in that environment. | Do not bypass; contact the environment owner. |
| HTTP `429` | Endpoint rate limit exceeded. | Retry with bounded backoff. |
| HTTP `503` from revocation | Revocation could not be committed. | Assume the token remains valid and retry safely. |

Authorization redirects use standard OAuth `error` values and stable AIRCTRL
`error_description` codes:

| `error` | `error_description` | Meaning |
| - | - | - |
| `unsupported_response_type` | `oauth_response_type_invalid` | `response_type` was not `code`. |
| `invalid_request` | `oauth_pkce_s256_required` | PKCE was absent, malformed, or not `S256`. |
| `invalid_scope` | `oauth_scopes_required` | The authorization request contained no scope. |
| `invalid_scope` | `oauth_scope_invalid` | A requested scope is unknown or disabled on the App. |
| `access_denied` | `oauth_client_account_membership_required` | The user is not an active member of the App's organization. |
| `access_denied` | `oauth_installations_connect_required` | The user lacks permission to approve the connection. |
| `access_denied` | `oauth_target_account_unavailable` | A public App has no eligible active organization for this user. |
| `access_denied` | `subscription_inactive` | The organization lacks active product access. |

The decision endpoint also rejects a missing or changed organization with `oauth_target_required` or
`oauth_target_invalid`. Never retry with an organization that was not shown by the prepared consent
response.

A user denial returns `error=access_denied` without an `error_description`.

## Security requirements

* Keep the client secret, verifier, provider keys, and all returned tokens in the confidential backend.
* Use a fresh state and PKCE verifier for every authorization transaction.
* Validate both `state` and RFC 9207 `iss` before exchanging the code.
* Use exact discovery-derived endpoints; do not construct them from a browser URL.
* Serialize refreshes and replace refresh tokens atomically.
* Never log Basic authorization, token request bodies, token responses, or callback codes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.