# OAuth 2.0

> Authenticate users with the OAuth 2.0 authorization code flow and PKCE, exchange and refresh tokens, and request scopes.

Source: https://developer.kustomer.com/docs/get-started/oauth

Use OAuth when your software should act as an individual Kustomer user rather than as a shared machine user. Actions appear under that person's name and are limited to what they're allowed to do. The [`kusty` CLI](https://developer.kustomer.com/docs/ai/kusty) uses this flow for `kusty auth login`.

Kustomer implements the **authorization code** grant with **PKCE** (`S256`) and **refresh tokens**.

_Step-by-step sequence of the OAuth 2.0 authorization code flow with PKCE between your app, the browser, the Kustomer web host and the Kustomer API host._

## Endpoints

Both endpoints are on your customer's org, not on a global host:

| Step      | URL                                             |
| --------- | ----------------------------------------------- |
| Authorize | `https://<org>.kustomerapp.com/oauth/authorize` |
| Token     | `https://<org>.api.kustomerapp.com/oauth/token` |

Ask the user for their org name (the subdomain they sign in at) before you start the flow.

## 1. Set up an OAuth client

An OAuth client is an app installed in the org. Its app definition declares the allowed `redirectUris`:

```json title="app definition (excerpt)"
{
  "app": "acme_assistant",
  "version": "1.0.0",
  "title": "Acme Assistant",
  "visibility": "private",
  "redirectUris": ["https://assistant.acme.example/oauth/callback"]
}
```

1. **Register** the app definition. The response includes the app's `clientId`.
2. **Install** it in the org. Authorization only succeeds once the app is installed there, not merely registered.
3. **Mint a client secret** for confidential clients (servers that can keep a secret). The secret is returned once.

See [Apps platform › OAuth](https://developer.kustomer.com/docs/apps/oauth) for the registration and secret endpoints, and [Register and install](https://developer.kustomer.com/docs/apps/register-and-install) for the full app lifecycle.

> **Native and CLI apps:** For apps on a user's machine, register loopback redirect URIs such as `http://localhost:53682/callback`. Register a few ports so you have a fallback if one is busy. This is what `kusty` does.

## 2. Send the user to authorize

Generate a random `code_verifier` and `state`, store them for this login attempt, and redirect:

```text
https://acme.kustomerapp.com/oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fassistant.acme.example%2Foauth%2Fcallback
  &response_type=code
  &scope=org.permission.customer%20org.permission.conversation%20offline_access
  &state=Xy9cPq...
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
```

**Authorize parameters**

The user signs in to their org and approves. Kustomer redirects back to your `redirect_uri` with `code` and `state`, or with `error` and `error_description` if they declined.

## 3. Exchange the code

```bash title="cURL"
curl -X POST "https://acme.api.kustomerapp.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=https://assistant.acme.example/oauth/callback" \
  --data-urlencode "code_verifier=$CODE_VERIFIER" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET"
```

The body is form-encoded, not JSON. A successful response:

```json title="200 OK"
{
  "access_token": "eyJhbGciOi...",
  "refresh_token": "def50200...",
  "token_type": "Bearer",
  "expires_in": 86400,
  "scope": "org.permission.customer org.permission.conversation offline_access"
}
```

Errors use the standard OAuth shape: `{"error": "invalid_grant", "error_description": "..."}`.

## 4. Call the API

Send the access token exactly like an API key:

```bash
curl "https://acme.api.kustomerapp.com/v1/users/current" -H "Authorization: Bearer $ACCESS_TOKEN"
```

Access tokens last about 24 hours. Use `expires_in` rather than hard-coding a lifetime.

## 5. Refresh

```bash title="cURL"
curl -X POST "https://acme.api.kustomerapp.com/oauth/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET"
```

> **Refresh needs a client secret, and refresh tokens rotate:** Kustomer accepts PKCE without a secret for the first login, but the `refresh_token` grant requires client credentials. A client with only a `client_id` can log in but gets `invalid_client` when it tries to refresh.
>
> Each refresh returns a **new** refresh token. Store it before using the new access token, and don't retry a refresh that failed with a `4xx`: the old token may already be spent.

## Complete example

A minimal Node.js login for a local tool, with no dependencies beyond the standard library:

```ts title="Node.js"
import { createHash, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'

const org = 'acme'
const clientId = process.env.KUSTOMER_CLIENT_ID!
const clientSecret = process.env.KUSTOMER_CLIENT_SECRET!
const redirectUri = 'http://localhost:53682/callback'

const b64url = (b: Buffer) => b.toString('base64url')
const verifier = b64url(randomBytes(32))
const challenge = b64url(createHash('sha256').update(verifier).digest())
const state = b64url(randomBytes(16))

const authorize = new URL(`https://${org}.kustomerapp.com/oauth/authorize`)
authorize.search = new URLSearchParams({
  client_id: clientId,
  redirect_uri: redirectUri,
  response_type: 'code',
  scope: 'org.permission.customer org.permission.conversation offline_access',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
}).toString()
console.log('Open this URL to sign in:\n', authorize.href)

const code = await new Promise<string>((resolve, reject) => {
  const server = createServer((req, res) => {
    const url = new URL(req.url!, redirectUri)
    if (url.searchParams.get('state') !== state) return reject(new Error('state mismatch'))
    res.end('Signed in. You can close this tab.')
    server.close()
    resolve(url.searchParams.get('code')!)
  }).listen(53682)
})

const tokens = await fetch(`https://${org}.api.kustomerapp.com/oauth/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: redirectUri,
    code_verifier: verifier,
    client_id: clientId,
    client_secret: clientSecret,
  }),
}).then((r) => r.json())

const me = await fetch(`https://${org}.api.kustomerapp.com/v1/users/current`, {
  headers: { Authorization: `Bearer ${tokens.access_token}` },
}).then((r) => r.json())
console.log('Signed in as', me.data.attributes.name)
```

## Scopes

Scopes are Kustomer roles, written without an action to cover every action on a resource: `org.permission.customer`, `org.permission.conversation`, `org.permission.message`, `org.permission.user`. Add `offline_access` to get a refresh token. A token never has more access than the user who approved it, whatever scopes you request.

See [API keys and roles](https://developer.kustomer.com/docs/get-started/api-keys-and-roles#how-roles-are-named) for how role names are built.
