# OAuth for integrations Build an app that works in InfraNest for other people. With OAuth, the person clicks **Connect with InfraNest** in your app, approves it in InfraNest, and your app receives a token. Nobody creates or copies an API token, and they can remove your app whenever they like. InfraNest is an OAuth 2.1 authorization server: the authorization code flow with PKCE, and refresh tokens. ## OAuth or an API token? | Use | When | |---|---| | **An API token** | Your own scripts, CI and tools, acting as you. See [Getting started](getting-started.md). | | **OAuth** | An app other people connect to their own InfraNest account: an integration, a hosted service, an AI assistant. | Both reach the same API with the same [scopes](scopes.md), and the same limits apply: what a token can never do, the rate limits and your organization's IP allowlist. An OAuth token can do no more than the person who approved it could do themselves. ## How it works 1. Your app sends the person's browser to InfraNest's **authorize** address. 2. They sign in, if they are not already, and see a **consent page**: your app's name, the address it sends them back to, and what it asks to do. They pick **one organization** your app may use. A line their role in that organization cannot grant is struck out, and your app does not get it. 3. They select **Allow**. InfraNest sends the browser back to your redirect URI with a **code**. 4. Your app exchanges the code for an **access token** and a **refresh token**. 5. Your app calls the API with the access token, and refreshes it when it expires. The token works in the organization they picked, and only there. To use a second organization, they connect again and pick the other one. ## Before you start You need a **client ID** and your **redirect URI** registered with it. See [Getting a client](#getting-a-client). PKCE is required for every client. Make a fresh **code verifier** for each sign-in: a random string of 43 to 128 characters. The **code challenge** is its SHA-256 hash, base64url-encoded without padding: ```sh CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '=+/\n' | cut -c1-64) CODE_CHALLENGE=$(printf %s "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n') STATE=$(openssl rand -hex 16) ``` Keep `CODE_VERIFIER` and `STATE` with the person's session in your app. You need both when they come back. ## 1. Send the person to InfraNest Open this address in their browser (shown on several lines here; it is one URL): ``` https://api.infranest.io/oauth/authorize ?response_type=code &client_id=YOUR_CLIENT_ID &redirect_uri=https%3A%2F%2Fapp.example.com%2Finfranest%2Fcallback &scope=dns.read%20monitoring.read &state=STATE &code_challenge=CODE_CHALLENGE &code_challenge_method=S256 ``` | Parameter | Value | |---|---| | `response_type` | Always `code`. | | `client_id` | Your client ID. | | `redirect_uri` | Exactly one of the redirect URIs registered for your client. | | `scope` | The [scopes](scopes.md) your app needs, separated by spaces. Ask for the fewest that work. | | `state` | A random value. InfraNest sends it back unchanged; check that it matches. | | `code_challenge` | The code challenge from above. | | `code_challenge_method` | Always `S256`. | If the client ID is unknown, or the redirect URI is not one registered for it, InfraNest shows an error and never sends the person to your app (see [Errors](#errors)). Any other problem is sent back to your redirect URI as `?error=…&error_description=…&state=…`. ## 2. The person approves InfraNest shows its consent page. You do not build anything for this step. The page tells the person: - your app's name and the host of your redirect URI, and whether InfraNest has **verified** the app; - what it asks for: **See** for a read scope, **See and change** for a write scope. An app that asks for full access (`*`) gets an extra confirmation; - which organization it may use, and what their role there lets them grant. The consent page is valid for five minutes and works once. If the person waits longer, send them to the authorize address again. They select **Allow** and come back to your redirect URI: ``` https://app.example.com/infranest/callback?code=def50200a1b2...&state=STATE ``` Or they select **Cancel**: ``` https://app.example.com/infranest/callback?error=access_denied&state=STATE ``` Check that `state` matches the one you stored before you do anything else. If it does not, stop. ## 3. Exchange the code The code is valid for ten minutes and works once. Exchange it at the token endpoint with the code verifier: ```sh curl -X POST https://api.infranest.io/oauth/token \ -H "Accept: application/json" \ -d grant_type=authorization_code \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d redirect_uri=https://app.example.com/infranest/callback \ -d code=THE_CODE \ -d code_verifier=$CODE_VERIFIER ``` Leave out `client_secret` if your client has none (a public client, such as an AI assistant or a desktop app). A client with a secret may also send it as HTTP Basic authentication instead. ```json { "token_type": "Bearer", "expires_in": 3600, "access_token": "", "refresh_token": "" } ``` Treat both tokens as opaque strings. Their format may change. ## 4. Call the API Send the access token exactly as you would an API token: ```sh curl https://api.infranest.io/api/organizations \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Accept: application/json" ``` `GET /api/organizations` answers with the organization the person picked: use its `id` in every other path, for example `/api/organizations/42/dns-zones`. Everything in the [API reference](reference.md) works, within the scopes granted. Changes your app makes appear in the organization's audit log under your app's name. ## 5. Refresh the token An access token lasts **one hour**. Before it expires, or when the API answers `401`, refresh it: ```sh curl -X POST https://api.infranest.io/oauth/token \ -H "Accept: application/json" \ -d grant_type=refresh_token \ -d client_id=YOUR_CLIENT_ID \ -d client_secret=YOUR_CLIENT_SECRET \ -d refresh_token=$REFRESH_TOKEN ``` The answer has the same shape as the code exchange: a new access token **and a new refresh token**. - **Refresh tokens rotate.** Each one works once. The refresh revokes the one you sent, so save the new one before you use the new access token. If two parts of your app refresh at the same time, one of them gets `invalid_grant`: let one place do the refreshing. - **A refresh token lasts 30 days.** Each refresh starts a new 30 days, so an app that is used keeps working. One unused for 30 days has to send the person through the flow again. - **The scopes and the organization stay as approved.** To ask for more, send the person through the flow again. ## When access ends The person can remove your app at any time under **My account → Authorized apps**, and an organization's owners and admins can remove it under **Settings → Authorized apps**. Leaving the organization, or being removed from it, ends it too. Your app sees this as: - the API answering `401` with the code [`unauthenticated`](errors.md#unauthenticated), and - the refresh then answering `400` with `invalid_grant`. Then stop using the stored tokens, tell the person the connection ended, and offer **Connect with InfraNest** again. ## Discovery Clients that read OAuth metadata, such as MCP clients, find everything at: ```sh curl https://api.infranest.io/.well-known/oauth-authorization-server ``` ```json { "issuer": "https://api.infranest.io", "authorization_endpoint": "https://api.infranest.io/oauth/authorize", "token_endpoint": "https://api.infranest.io/oauth/token", "registration_endpoint": "https://api.infranest.io/oauth/register", "response_types_supported": ["code"], "code_challenge_methods_supported": ["S256"], "scopes_supported": ["mcp:use"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"], "client_id_metadata_document_supported": true } ``` The [MCP server](ai-assistants.md) describes itself at `https://api.infranest.io/.well-known/oauth-protected-resource/mcp`, and its `401` names that address in the `WWW-Authenticate` header. `scopes_supported` lists only `mcp:use`, the scope an assistant asks for; every scope in the [scopes table](scopes.md) can be requested. ## Scopes OAuth uses the same scopes as API tokens: see the [scopes table](scopes.md). Write includes read. Two things are particular to OAuth: - **`mcp:use`** lets a client use the MCP server and grants nothing else. A request for `mcp:use` alone is shown to the person as every read scope, because the MCP server only reads. - **What the person may grant decides.** Your app gets the scopes it asked for that the person's role in the chosen organization allows, and no others. Check what you can do from the API's answers: a scope you were not granted answers [`scope_missing`](errors.md#scope_missing). ## Lifetimes | What | Lasts | |---|---| | Consent page | 5 minutes, once | | Authorization code | 10 minutes, once | | Access token | 1 hour | | Refresh token | 30 days, once; each refresh issues a new one | ## Getting a client **AI assistants with a published identity need no registration.** If your client publishes a [Client ID Metadata Document](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document-00), send its URL as the `client_id` — on the authorize request and at the token endpoint. Claude does this with **Use Claude's published identity** (`https://claude.ai/oauth/mcp-oauth-client-metadata`), and ChatGPT with `https://chatgpt.com/oauth/…/client.json`. We accept documents from a fixed list of hosts, currently `claude.ai` and `chatgpt.com`; for another, email support@infranest.io. The document must: - name itself: its `client_id` is exactly the URL it is served from (https, with a path); - have a `client_name` and `redirect_uris` (https, or `http` on a loopback address), and the redirect URI you send must be one of them exactly; - be a public client: no `client_secret`, and `token_endpoint_auth_method` `none` (or left out). Prove yourself with PKCE `S256` as every client does. The consent page shows the app under its document's name as **Verified by InfraNest** — unless every redirect URI is a loopback address, which any program on the person's machine could be listening on. If we cannot accept the document, the authorize request answers `400` with `code: invalid_client` and a `reason` (`host_not_allowed`, `not_https`, `malformed_url`, `client_id_mismatch`, `invalid_document`, `unsupported_auth`, or `unreachable` when we could not fetch it). We cache a document for its `Cache-Control: max-age` (between a minute and a day, an hour if it sends none), so a change to it reaches us within that time. **Other AI assistants register themselves.** Claude, ChatGPT and other MCP clients call `POST https://api.infranest.io/oauth/register` the first time someone adds InfraNest, so there is nothing to set up. Self-registration is open to a fixed list of redirect addresses — `https://claude.ai`, `https://chatgpt.com`, `https://chat.openai.com`, and the `cursor://`, `vscode://` and `claude://` schemes — and limited to 10 registrations an hour from one address. The consent page shows such an app as **Not verified by InfraNest**. A name containing "InfraNest", or the name of a verified app, is refused (`invalid_client_metadata`), so a self-registered app cannot pass itself off as us or as an integration we verified. A self-registered client that obtains no token for 30 days is removed. A consent link belongs to the first person who opens it: anyone else who opens or answers it sees it as expired. **Integrations get a verified client from us.** Email **support@infranest.io** with: - your app's name, as people should see it on the consent page, and a logo; - your redirect URIs: `https`, or `http` only on `localhost` or `127.0.0.1` while you develop; - the scopes your app needs. We register the app and send you a client ID and a **client secret**. The secret is shown once; store it like a password. The consent page then shows your app as **Verified by InfraNest**. When you need a new secret, ask: the previous one keeps working for seven days, so you can switch without an outage. ## Errors | Where | Answer | What it means | |---|---|---| | Authorize | `400 {"code": "invalid_client"}` | The client ID is unknown, or its registration was removed. | | Authorize | `400 {"code": "invalid_redirect_uri"}` | `redirect_uri` is not one registered for this client. InfraNest never redirects to it. | | Your redirect URI | `?error=invalid_request` | A parameter is missing or wrong — most often the PKCE `code_challenge`. | | Your redirect URI | `?error=invalid_scope` | A scope that does not exist. Check the [scopes table](scopes.md). | | Your redirect URI | `?error=access_denied` | The person selected **Cancel**. | | Token | `400 {"error": "invalid_grant"}` | The code or refresh token is unknown, expired, already used or revoked, or the `code_verifier` or `redirect_uri` does not match. Send the person through the flow again. | | Token | `401 {"error": "invalid_client"}` | The client ID or secret is wrong. | | Register | `400 {"error": "invalid_redirect_uri"}` | A redirect URI outside the self-registration list. | | API | `401` [`unauthenticated`](errors.md#unauthenticated) | The access token expired or was revoked. Refresh it. | | API | `403` [`scope_missing`](errors.md#scope_missing) | The token lacks the scope named in `required`. | Every other API error is the same as for an API token: see [Errors](errors.md). ## Keeping it safe - **Always use PKCE and `state`.** A fresh code verifier and state for every sign-in, checked when the person comes back. - **Store refresh tokens like passwords**: encrypted, server-side, never in a browser's storage or a log. A refresh token is a standing key to the person's organization. - **Keep the client secret on your server.** An app that runs on people's devices cannot keep a secret; ask for a public client instead, which relies on PKCE alone. - **Ask for the fewest scopes.** People approve a short, plain list more readily, and a leaked token can do less.