# Token

Exchange an authorization code for tokens, and refresh them later.

The back-channel half of the flow. Call it from your **server** with your client
secret — never from a browser. It serves two grants: `authorization_code` to
turn a fresh code into tokens, and `refresh_token` to get new ones without
sending the user back.

**POST** `/api/auth/oauth2/token`

> [!WARNING]
> The body must be `application/x-www-form-urlencoded`. Sending JSON answers
> `415 Unsupported Media Type` — this is the single most common integration
> mistake.

## Authorizations

Authenticate the app with either method. If both are present, the header wins.

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `Authorization` | string | header | no | `Basic base64(client_id:client_secret)` — the `client_secret_basic` method. |
| `client_id` | string | body | no | The `client_secret_post` method: send `client_id` and `client_secret` as form fields instead of the header. |
| `client_secret` | string | body | no | Your app's secret. Required for every grant — haunt.gg has no public clients. |

## Body — authorization code grant

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `grant_type` | string | body | yes | `authorization_code`. |
| `code` | string | body | yes | The single-use code from your redirect URL. Valid for 10 minutes. |
| `redirect_uri` | string | body | yes | The exact same value you sent to the authorization endpoint. A mismatch answers `invalid_request`. |
| `code_verifier` | string | body | yes | The PKCE verifier whose SHA-256 hash you sent as `code_challenge`. |
| `resource` | string | body | no | Optional. Switches the access token from an opaque string to a signed JWT for that audience. Only `https://haunt.gg/api/auth` and `https://haunt.gg/api/auth/oauth2/userinfo` are accepted; anything else answers `invalid_request`. Most integrations should leave this out. |

## Body — refresh token grant

| Name | Type | In | Required | Description |
| --- | --- | --- | --- | --- |
| `grant_type` | string | body | yes | `refresh_token`. |
| `refresh_token` | string | body | yes | The refresh token from a previous response. Every use rotates it — store the new one. |
| `scope` | string | body | no | Optional down-scoping. Every entry must already be carried by the refresh token; asking for more answers `invalid_scope`. Omit it to keep the same scopes. |

## Example Request

  #### cURL

```bash
curl -X POST "https://haunt.gg/api/auth/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "code=1a2B3c4D5e6F7g8H9i0J1k2L3m4N5o6P" \
  -d "redirect_uri=https://example.com/auth/callback" \
  -d "code_verifier=YOUR_PKCE_VERIFIER"
```
  #### JavaScript

```ts
const res = await fetch("https://haunt.gg/api/auth/oauth2/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    code,
    redirect_uri: "https://example.com/auth/callback",
    code_verifier: verifier,
  }),
});
const tokens = await res.json();
```
  #### Refresh

```bash
curl -X POST "https://haunt.gg/api/auth/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=YOUR_REFRESH_TOKEN"
```

## Response

  #### 200 — Tokens issued, with Cache-Control: no-store. `refresh_token` is only present when the granted scopes include offline_access, and `id_token` only when they include openid. `expires_at` is a non-standard convenience field (absolute unix seconds) — `expires_in` is the one to rely on.

```json
{
  "access_token": "aBcDeFgHiJkLmNoPqRsTuVwXyZaBcDeF",
  "token_type": "Bearer",
  "expires_in": 3600,
  "expires_at": 1767225600,
  "refresh_token": "gHiJkLmNoPqRsTuVwXyZaBcDeFgHiJkL",
  "scope": "openid identify offline_access",
  "id_token": "eyJhbGciOiJFZERTQSIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL2hhdW50LmdnIn0.…"
}
```
  #### 400 — The request or the code was rejected. `error` is the OAuth code, `error_description` explains it.

```json
{
  "error": "invalid_request",
  "error_description": "redirect_uri mismatch"
}
```
  #### 401 — The code, the refresh token or the client credentials did not check out.

```json
{
  "error": "invalid_grant",
  "error_description": "invalid code"
}
```
  #### 415 — The body was not form-encoded.

```json
{
  "message": "Unsupported media type"
}
```

## Errors

| `error` | Typical `error_description` | Meaning |
| --- | --- | --- |
| `invalid_request` | `redirect_uri mismatch` | The `redirect_uri` differs from the one used at `/authorize`. |
| `invalid_request` | `PKCE is required for this client` | You sent no `code_verifier`. |
| `invalid_request` | `code verification failed` | The verifier does not hash to the challenge you sent. |
| `invalid_request` | `session no longer exists` | The haunt.gg session that approved the code was signed out before you exchanged it. |
| `invalid_grant` | `invalid code` | Unknown, already-used or expired code. |
| `invalid_grant` | `invalid refresh token` | Expired, unknown, or already rotated. |
| `invalid_client` | `invalid client_id` | The code or refresh token belongs to a different app. |
| `invalid_client` | `client secret must be provided` | No client authentication was sent. |
| `invalid_client` | `client is disabled` | The app was suspended by haunt.gg staff. |
| `invalid_scope` | `unable to issue scope …` | The refresh request asked for a scope the token does not carry. |
| `unauthorized_client` | `client is not authorized to use grant type …` | Only `authorization_code` and `refresh_token` are enabled for haunt.gg apps. |

## Refresh tokens rotate

Every successful refresh invalidates the token you presented and issues a new
one, and resets the 30-day window. **Always store the refresh token from the
latest response.**

> [!WARNING]
> Presenting a refresh token that was already rotated is treated as a stolen
> token: haunt.gg answers `invalid_grant` and deletes **every** refresh token
> your app holds for that user, along with the access tokens issued from them.
> The user has to go through the authorization flow again.
> 
> This is standard reuse detection, and the usual trigger is not an attacker but
> two workers refreshing the same token at the same time. Refresh in one place
> and share the result.

## Rate limit

100 requests per minute per IP address, counted for this endpoint alone.
