# OAuth 2.0 with PKCE

How your app gets the merchant's consent and an access token, and how it refreshes the token without losing access.

## Overview

1. The merchant installs your app or opens it from the dashboard, and LARM opens your launch URL with `larm_store_id`.
2. Your server generates a random `code_verifier` and a `state`, and redirects the browser to `/oauth/authorize`.
3. LARM shows the store owner the requested scopes. On approval, the browser returns to your `redirect_uri` with `code` and `state`.
4. Your server exchanges the `code` for a token pair at `/oauth/token`, using the `code_verifier`.
5. Your server calls the API with `Authorization: Bearer`, and refreshes the token before it expires.

> Every call to the token endpoint is made from your server with Basic authentication. There is no browser flow and no public-client flow without a secret.

## Client authentication

`/oauth/token`, `/oauth/launch/verify` and `/oauth/operations/*` accept one method only: `client_secret_basic`.

```http
Authorization: Basic base64(CLIENT_ID:CLIENT_SECRET)
```

- `client_id` is a UUID.
- The secret starts with `lar_cs_` followed by 43 characters.
- Do not send `client_id` or the secret in the request body; a body containing unknown keys is rejected.

When you rotate the secret in the portal, the previous secret stays valid for 24 hours, so deploy the new secret to your servers within that window. Revocation takes effect immediately.

## 1. Start authorization

Generate a random `code_verifier` of 43 to 128 characters and derive the `code_challenge` from it:

```js
import crypto from 'node:crypto'

const verifier = crypto.randomBytes(32).toString('base64url')          // 43 characters
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
const state = crypto.randomBytes(24).toString('base64url')
// Store verifier and state in your server session, bound to the store, then redirect the browser:
const url = new URL('https://larm.sa/oauth/authorize')
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: process.env.LARM_CLIENT_ID,
  store_id: storeId,                       // from larm_store_id
  redirect_uri: 'https://app.example.com/larm/callback',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
  scope: 'products:read orders:read',
})
res.redirect(url.href)
```

| Parameter | Rule |
|---|---|
| `response_type` | `code` only |
| `client_id` | UUID |
| `store_id` | UUID, taken from `larm_store_id` in the launch URL |
| `redirect_uri` | Exact match of one of the redirect URLs registered in the version |
| `state` | 16 to 512 characters, required |
| `code_challenge` | 43 base64url characters |
| `code_challenge_method` | `S256` only |
| `scope` | Space-separated, up to 2048 characters |
| `version_id` | Optional, UUID of a specific version |

Rules for the requested scopes:

- Include every **required** scope of the version.
- Request nothing beyond the required and optional scopes combined.
- Include each scope's dependencies. Requesting `orders.digital:read` without `orders:read` is rejected with `SCOPE_DEPENDENCIES_MUST_BE_VISIBLE`.

Parameters are a closed set: any unknown or repeated parameter is rejected. Errors at this step are returned **as JSON from the authorization endpoint itself**, and are never sent to `redirect_uri`.

> **Warning:** Authorization is limited to 30 requests per minute per app, store and IP address, and 20 invalid requests per minute per caller. Exceeding either returns 429 `AUTHORIZE_RATE_LIMITED`.

## 2. Merchant consent

The consent page lives in the merchant dashboard, and only the store owner can see it. On approval, the browser returns to:

```http
https://app.example.com/larm/callback?code=lar_c_…&state=…
```

On denial:

```http
https://app.example.com/larm/callback?error=access_denied&state=…
```

Compare `state` with the value you stored before doing anything else. The authorization code is valid for **at most two minutes** and can be used once.

## 3. Exchange the code for tokens

```bash
curl -X POST "https://larm.sa/oauth/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=$CODE" \
  -d "redirect_uri=https://app.example.com/larm/callback" \
  -d "code_verifier=$VERIFIER"
```

The body accepts `application/x-www-form-urlencoded` or `application/json`, and the only allowed keys are `grant_type`, `code`, `redirect_uri`, `code_verifier` and `refresh_token`.

```json
{
  "access_token": "lar_at_Q2mV0hN8sYc1kP4tRw7Xa.4f0jK2…",
  "refresh_token": "lar_rt_Hc8nW1qLp5sVz0bTy3Ue.9aQm…",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "products:read orders:read",
  "store_id": "5b1d7a62-0c4e-4f38-9a51-2e7b3c9d8f10",
  "installation_id": "c7e4a1f2-6b3d-4e8a-9c05-1d2f3a4b5c6d",
  "installation_generation": 1,
  "grant_revision": 1,
  "operation_id": "0e9d8c7b-6a5f-4e3d-2c1b-0a9f8e7d6c5b"
}
```

| Field | Meaning |
|---|---|
| `access_token` | Valid for 900 seconds. Starts with `lar_at_` |
| `refresh_token` | Valid for 30 days from the last refresh. Starts with `lar_rt_` |
| `scope` | What the merchant actually approved. It can be narrower than what you requested if the merchant declined optional scopes |
| `installation_id` | The installation's identity. Store it with the tokens |
| `installation_generation` | Increases on reinstall. Any token from an earlier generation is no longer valid |
| `grant_revision` | Increases when the granted scopes change |
| `operation_id` | Used for recovery if the response is lost, as described below |

## 4. Refresh the token

```bash
curl -X POST "https://larm.sa/oauth/token" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN"
```

Refresh tokens **rotate, and rotation is mandatory**:

- Every refresh issues a new pair and revokes the previous access token immediately.
- A refresh token can be used **once**. Persist the new pair before doing anything else.
- The 30-day window restarts on every refresh, so an active app never needs the merchant's consent again.

> **Important:** If you resend an authorization code or refresh token **after** it was exchanged successfully, LARM treats it as token theft and revokes every token for this installation (`TOKEN_REUSE_DETECTED`); the merchant must authorize again. Never retry automatically after a successful response, and run refreshes from a single process per installation.

Two identical concurrent requests are treated as one, so a double submit is safe. The risk is retrying after a success.

## If the response is lost

If the connection drops and you do not know whether the exchange succeeded:

1. Send the same body to `POST /oauth/operations/lookup` to get the operation's state.
2. If the result is `RESULT_UNAVAILABLE_REAUTHORIZE`, call `POST /oauth/operations/recover` with the `operation_id`. Any tokens that were issued are revoked; then authorize again.

LARM never resends tokens that were already issued; recovery guarantees that no orphaned token pair stays valid.

## Error format

LARM's OAuth errors use the same unified format as the rest of the API, **not** the RFC 6749 text format:

```json
{ "error": { "code": "PKCE_MISMATCH" }, "meta": { "request_id": "…" } }
```

| Code | HTTP | Meaning |
|---|---|---|
| `INVALID_CLIENT` | 401 | Client authentication failed or the secret is invalid |
| `INVALID_GRANT_TYPE` | 400 | Unknown `grant_type` |
| `INVALID_GRANT` | 400 | Missing fields or wrong token prefix |
| `INVALID_CODE` | 400 | The authorization code was not approved or has expired |
| `REDIRECT_URI_MISMATCH` | 400 | `redirect_uri` differs from the one used in authorization |
| `PKCE_MISMATCH` | 400 | `code_verifier` does not match `code_challenge` |
| `INVALID_REFRESH_TOKEN` | 400 | The refresh token is unknown or inactive |
| `REFRESH_ALREADY_USED` | 400 | The refresh token was already exchanged |
| `TOKEN_REUSE_DETECTED` | 400 | Resent after success; all tokens were revoked |
| `RECONSENT_REQUIRED` | 403 | The scopes changed and new consent is required |
| `RESULT_UNAVAILABLE_REAUTHORIZE` | 409 | The result was lost; recover, then authorize again |
| `RATE_LIMITED` | 429 | Rate limit exceeded |
| `STORE_MIGRATING` | 503 | The store is moving; retry shortly |

## Not supported

- No `client_credentials` grant; every token is bound to a store and its owner's consent.
- No OpenID Connect and no `id_token`.
- No token revocation or introspection endpoint. The merchant uninstalls the app from the dashboard, and you receive the [`app.uninstalled`](/guides/app-uninstall) webhook.
