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

# Authorization Code Flow with PKCE

> The full end-to-end OAuth flow with sequence diagram and parameter reference

The Authorization Code Grant with PKCE is the **only** user-facing grant Flexslot supports. This page is the complete reference: every parameter, every header, every status code.

## The flow at a glance

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant U as User (Browser)
    participant C as Your Client
    participant AS as Flexslot Authorization Server
    participant RS as Flexslot Resource Server

    C->>C: Generate code_verifier + code_challenge (S256)
    C->>C: Generate state (random)
    C->>U: 302 to flexslot.gg/oauth/authorize?response_type=code&...&code_challenge=...
    U->>AS: GET flexslot.gg/oauth/authorize (browser-facing consent page)
    AS->>U: Render consent screen
    U->>AS: User clicks Allow
    AS->>U: 302 to redirect_uri?code=...&state=...&iss=...
    U->>C: GET /callback?code=...&state=...&iss=...
    C->>C: Verify state matches
    C->>C: Verify iss = https://api.flexslot.gg
    C->>AS: POST api.flexslot.gg/api/public/v1/oauth/token (code, code_verifier, client_id [+secret])
    AS->>AS: Validate code_verifier vs stored code_challenge
    AS->>C: 200 {access_token, refresh_token, ...}
    C->>RS: GET /api/public/v1/games/magic-the-gathering/decks with Bearer access_token
    RS->>C: 200 {results: [...]}
```

## Why PKCE?

Without PKCE, anyone who intercepts the authorization code (browser history, server logs, a malicious app handling your custom URL scheme) can exchange it for tokens. PKCE binds the code to a secret your client generated **before** the flow started.

* Your client generates `code_verifier` — a random 43-128 character string.
* Your client sends `code_challenge = BASE64URL(SHA256(code_verifier))` to `/authorize`.
* Flexslot stores the challenge against the issued code.
* When your client redeems the code at `/token`, it sends the original `code_verifier`.
* Flexslot recomputes `SHA256(code_verifier)` and compares to the stored challenge.

If the values don't match, you get `invalid_grant` and the code is invalidated. See [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) for the spec.

<Warning>
  `code_challenge_method=plain` is **forbidden** at Flexslot. Use `S256` only. RFC 9700 deprecates `plain` because it provides no actual protection.
</Warning>

## Generating PKCE values

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto'

  function base64url(buffer) {
    return buffer
      .toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '')
  }

  export function generatePkce() {
    const verifier = base64url(crypto.randomBytes(32))
    const challenge = base64url(crypto.createHash('sha256').update(verifier).digest())
    return { verifier, challenge, method: 'S256' }
  }
  ```

  ```python Python theme={null}
  import base64
  import hashlib
  import secrets

  def generate_pkce() -> dict[str, str]:
      verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b'=').decode('ascii')
      challenge = base64.urlsafe_b64encode(
          hashlib.sha256(verifier.encode('ascii')).digest()
      ).rstrip(b'=').decode('ascii')
      return {"verifier": verifier, "challenge": challenge, "method": "S256"}
  ```

  ```bash bash/openssl theme={null}
  # verifier: 43 chars base64url, no padding
  verifier=$(openssl rand 32 | base64 | tr '+/' '-_' | tr -d '=\n')

  # challenge = BASE64URL(SHA256(verifier))
  challenge=$(printf '%s' "$verifier" | openssl dgst -sha256 -binary \
    | base64 | tr '+/' '-_' | tr -d '=\n')

  echo "verifier=$verifier"
  echo "challenge=$challenge"
  ```
</CodeGroup>

## Step 1 — Authorization request

Send the user's browser to the consent screen on the Flexslot web app:

```
GET https://flexslot.gg/oauth/authorize
```

<Note>
  This is `flexslot.gg`, **not** `api.flexslot.gg`. The authorization endpoint is a web page the user's browser opens — pointing the browser at the API host returns `401 FLEXSLOT_AUTHENTICATION_REQUIRED`. Only the token, introspection, and revocation endpoints live on `api.flexslot.gg`.
</Note>

### Required query parameters

| Parameter               | Value             | Description                                                      |
| ----------------------- | ----------------- | ---------------------------------------------------------------- |
| `response_type`         | `code`            | The only supported value.                                        |
| `client_id`             | `flx_client_…`    | The client ID from the partner admin.                            |
| `redirect_uri`          | Full URL          | Must **exactly match** a URI registered for this client.         |
| `scope`                 | Space-separated   | E.g. `decks:read sideboards:write`. See [Scopes](/oauth/scopes). |
| `state`                 | Random string     | CSRF token. You'll validate it on the callback.                  |
| `code_challenge`        | Base64url SHA-256 | From the PKCE generator above.                                   |
| `code_challenge_method` | `S256`            | Required. `plain` is forbidden.                                  |

### Example

```
https://flexslot.gg/oauth/authorize?
  response_type=code&
  client_id=flx_client_01HX2K…&
  redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback&
  scope=decks%3Aread%20sideboards%3Aread&
  state=4f8a9c1e2b3d…&
  code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
  code_challenge_method=S256
```

<Note>
  All query parameter values must be percent-encoded. Use your language's URL builder — don't hand-roll the string.
</Note>

## Step 2 — User consents

Flexslot renders the consent screen, showing the user:

* Your application's name and logo (from the partner admin)
* The scopes you requested, in human-readable form
* A clear **Allow** and **Deny** option

If the user clicks **Allow**, you get redirected with a `code`. If they click **Deny**, you get redirected with `error=access_denied`.

## Step 3 — Callback to your `redirect_uri`

```
GET https://your.app/oauth/callback?
  code=01HX2K6F8N9P…&
  state=4f8a9c1e2b3d…&
  iss=https://api.flexslot.gg
```

### What you must verify

<Steps>
  <Step title="state matches">
    Compare to the value you stored when starting the flow. If it doesn't match, abort — this is a CSRF attempt.
  </Step>

  <Step title="iss equals https://api.flexslot.gg">
    [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) mix-up defense. If a different `iss` shows up, you've been redirected to the wrong AS.
  </Step>

  <Step title="No error parameter">
    If `error=...` is present, the flow failed. See [errors](/oauth/errors#authorize-errors).
  </Step>
</Steps>

## Step 4 — Token exchange

```
POST https://api.flexslot.gg/api/public/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
```

### Body parameters

| Parameter       | Required?        | Description                                                 |
| --------------- | ---------------- | ----------------------------------------------------------- |
| `grant_type`    | Yes              | `authorization_code`                                        |
| `code`          | Yes              | The code from the callback                                  |
| `redirect_uri`  | Yes              | Same URI you used in step 1                                 |
| `code_verifier` | Yes              | The PKCE verifier you generated in step 1                   |
| `client_id`     | If public client | The client ID (confidential clients send it via Basic auth) |

### Authentication

| Client type                           | How to authenticate                                      |
| ------------------------------------- | -------------------------------------------------------- |
| **Confidential** (has client\_secret) | `Authorization: Basic <base64(client_id:client_secret)>` |
| **Public** (no secret)                | Include `client_id` in the body                          |

### Example request

```http theme={null}
POST /api/public/v1/oauth/token HTTP/1.1
Host: api.flexslot.gg
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Y2xpXzAxSFgyS…OnNlY3JldA==

grant_type=authorization_code
&code=01HX2K6F8N9P…
&redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
```

### Success response

`200 OK`, `Content-Type: application/json`, `Cache-Control: no-store`:

```json theme={null}
{
  "access_token": "flx_at_01HX2K6F8N9PRSTUVWXYZA…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "flx_rt_01HX2K6F8N9PRSTUVWXYZB…",
  "scope": "decks:read sideboards:read"
}
```

| Field           | Description                                                               |
| --------------- | ------------------------------------------------------------------------- |
| `access_token`  | Send as `Authorization: Bearer <token>` on API requests.                  |
| `token_type`    | Always `Bearer` (or `DPoP` if you opted in — see [DPoP](/oauth/dpop)).    |
| `expires_in`    | Seconds until the access token expires. Currently 3600.                   |
| `refresh_token` | Use to mint new access tokens. **Rotates on every use.**                  |
| `scope`         | The scopes actually granted. May be **narrower** than what you requested. |

### Error response

`400 Bad Request` (or `401` for `invalid_client`), `Content-Type: application/json`:

```json theme={null}
{
  "error": "invalid_grant",
  "error_description": "The authorization code is expired or has already been used."
}
```

See [Errors](/oauth/errors) for the full catalog.

## Step 5 — Call the API

The resource server is **game-scoped**: deck, guide, and card endpoints live under `/api/public/v1/games/{game}/...`, where `{game}` is the game's exact slug (for Magic, `magic-the-gathering`). There is no bare `/api/public/v1/decks/` — it returns 404. The slug is the `game` field on any deck or guide you read back.

```http theme={null}
GET /api/public/v1/games/magic-the-gathering/decks HTTP/1.1
Host: api.flexslot.gg
Authorization: Bearer flx_at_01HX2K6F8N9PRSTUVWXYZA…
```

The resource server returns the canonical Flexslot JSON envelope (`{ "error", "message", "details" }`) on auth/scope failures — it does **not** emit an RFC 6750 `WWW-Authenticate: Bearer` challenge.

A token lacking the required scope returns `403 Forbidden`:

```http theme={null}
HTTP/1.1 403 Forbidden
Content-Type: application/json
```

```json theme={null}
{
  "error": "FLEXSLOT_INSUFFICIENT_SCOPE",
  "message": "Caller does not have the required scope(s).",
  "details": { "required": ["decks:write"], "granted": ["decks:read"] }
}
```

An expired, revoked, or unknown token returns `401 Unauthorized` with the matching error code:

```json theme={null}
{ "error": "FLEXSLOT_OAUTH2_TOKEN_EXPIRED", "message": "OAuth2 access token has expired.", "details": {} }
```

```json theme={null}
{ "error": "FLEXSLOT_OAUTH2_TOKEN_REVOKED", "message": "OAuth2 access token has been revoked.", "details": {} }
```

```json theme={null}
{ "error": "FLEXSLOT_AUTHENTICATION_FAILED", "message": "Authentication failed.", "details": {} }
```

The only resource-server response carrying a `WWW-Authenticate` header is the DPoP lane (`Authorization: DPoP …`), which on proof failure returns `401` with `WWW-Authenticate: DPoP error="invalid_dpop_proof"`.

## Step 6 — Refresh

When the access token expires (or proactively, \~5 minutes before it does), exchange the refresh token for a new pair:

```http theme={null}
POST /api/public/v1/oauth/token HTTP/1.1
Host: api.flexslot.gg
Content-Type: application/x-www-form-urlencoded
Authorization: Basic Y2xpXzAxSFgyS…OnNlY3JldA==

grant_type=refresh_token
&refresh_token=flx_rt_01HX2K6F8N9PRSTUVWXYZB…
```

The response shape is identical to the initial exchange — **and includes a new refresh token**. Store the new one. The old one is now invalid.

<Warning>
  **Refresh tokens rotate.** If you replay a refresh token after it's been used, Flexslot revokes the entire grant. The user will have to re-consent. See [Security → Refresh token rotation](/oauth/security#refresh-token-rotation).
</Warning>

## Common gotchas

| Symptom                                             | Cause                                                                 | Fix                                                     |
| --------------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------- |
| `invalid_grant` on first exchange                   | Code already used, or expired (10 min)                                | Restart the flow                                        |
| `invalid_grant` with "code\_verifier mismatch"      | Verifier doesn't match the challenge                                  | Make sure you stored the verifier from the same session |
| `invalid_grant` with "redirect\_uri does not match" | Different URI in step 1 vs step 4                                     | They must be byte-for-byte identical                    |
| `invalid_client` (401)                              | Wrong client\_id, wrong secret, or wrong auth method                  | Check the partner admin                                 |
| `unsupported_grant_type`                            | You sent something other than `authorization_code` or `refresh_token` | Those are the only two grants supported                 |
| Browser stuck on `/authorize`                       | `redirect_uri` isn't registered for this client                       | Add it in the partner admin                             |

## What to read next

<CardGroup cols={2}>
  <Card title="Code samples" icon="code" href="/oauth/code-samples">
    Full Express, Flask, and curl implementations
  </Card>

  <Card title="Security" icon="shield" href="/oauth/security">
    PKCE, state, redirect URIs in depth
  </Card>

  <Card title="Errors" icon="circle-exclamation" href="/oauth/errors">
    Every error code explained
  </Card>

  <Card title="DPoP" icon="key" href="/oauth/dpop">
    Sender-constrained tokens
  </Card>
</CardGroup>
