---
title: "OAuth 2.1 with PKCE"
description: "OAuth 2.1 authorization server with dynamic client registration, authorization code flow with PKCE, and token management"
canonical: "https://docs.ainative.studio/docs/security/oauth-pkce"
last-updated: "2026-10-18T18:41:28.290Z"
---

# OAuth 2.1 with PKCE

Source: https://docs.ainative.studio/docs/security/oauth-pkce

> OAuth 2.1 authorization server with dynamic client registration, authorization code flow with PKCE, and token management

## OAuth 2.1 with PKCE

AINative Studio implements an OAuth 2.1 authorization server with three components:

1. **Dynamic Client Registration** (RFC 7591) -- programmatic registration of OAuth clients
2. **Authorization Code Flow with PKCE** (RFC 7636) -- secure token issuance for public clients (CLIs, SPAs)
3. **Token Management** -- exchange, refresh, and revocation

PKCE uses the S256 challenge method exclusively. The `plain` method is not supported.

---

## 1. Dynamic Client Registration

**Endpoint:** `POST /oauth/clients/register`

Implements RFC 7591. Allows MCP servers and other OAuth clients to register without manual configuration.

### Request

```json
{
  "client_name": "My MCP Server",
  "redirect_uris": ["http://localhost:8080/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "memory:read memory:write",
  "token_endpoint_auth_method": "none",
  "client_uri": "https://example.com",
  "logo_uri": "https://example.com/logo.png",
  "contacts": ["dev@example.com"],
  "tos_uri": "https://example.com/tos",
  "policy_uri": "https://example.com/privacy"
}
```

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `client_name` | string | Yes | -- | Human-readable name (1-255 chars) |
| `redirect_uris` | string[] | No | `[]` | Redirect URIs (must use http or https) |
| `grant_types` | string[] | No | `["authorization_code"]` | Supported: `authorization_code`, `refresh_token`, `client_credentials` |
| `response_types` | string[] | No | `["code"]` | Supported: `code` |
| `scope` | string | No | `""` | Space-separated scopes |
| `token_endpoint_auth_method` | string | No | `"client_secret_basic"` | One of: `client_secret_basic`, `client_secret_post`, `none` |
| `client_uri` | string | No | null | Client home page URL |
| `logo_uri` | string | No | null | Client logo URL |
| `contacts` | string[] | No | null | Contact email addresses |
| `tos_uri` | string | No | null | Terms of service URL |
| `policy_uri` | string | No | null | Privacy policy URL |

### Available Scopes

| Scope | Description |
|-------|-------------|
| `memory:read` | Read from memory/vector stores |
| `memory:write` | Write to memory/vector stores |
| `vector:search` | Search vector embeddings |
| `vector:write` | Write vector embeddings |
| `agent:read` | Read agent state and history |
| `agent:write` | Create and manage agents |
| `openid` | OpenID Connect identity |
| `profile` | User profile information |
| `email` | User email address |

### Response (201 Created)

```json
{
  "client_id": "a1b2c3d4-...",
  "client_secret": "dGhpcyBpcyBhIHNlY3JldA...",
  "client_name": "My MCP Server",
  "redirect_uris": ["http://localhost:8080/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "scope": "memory:read memory:write",
  "token_endpoint_auth_method": "none",
  "client_uri": "https://example.com",
  "logo_uri": "https://example.com/logo.png",
  "contacts": ["dev@example.com"],
  "tos_uri": "https://example.com/tos",
  "policy_uri": "https://example.com/privacy",
  "created_at": "2026-06-01T12:00:00+00:00"
}
```

Store `client_id` and `client_secret` securely. The secret is generated once and cannot be retrieved again.

---

## 2. Authorization Code Flow with PKCE

### Step 1: Generate PKCE Code Verifier and Challenge

The client generates a cryptographically random code verifier (43-128 URL-safe characters) and derives the S256 challenge:

```python
import secrets
import hashlib
import base64

## Generate verifier (43-128 chars)
code_verifier = secrets.token_urlsafe(64)[:64]

## Derive S256 challenge: BASE64URL(SHA256(verifier))
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
```

### Step 2: Authorization Request

Redirect the user to the authorization endpoint:

```
GET /oauth/authorize
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=http://localhost:8080/callback
  &scope=memory:read memory:write
  &state=<random_csrf_token>
  &code_challenge=<code_challenge>
  &code_challenge_method=S256
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `response_type` | Yes | Must be `code` |
| `client_id` | Yes | From client registration |
| `redirect_uri` | Yes | Must match a registered URI or the static allowlist |
| `scope` | Yes | Space-separated scopes |
| `state` | Recommended | CSRF protection; returned unchanged in the callback |
| `code_challenge` | Yes | S256 PKCE challenge |
| `code_challenge_method` | Yes | Must be `S256` |

**Redirect URI allowlist (static fallback):**

- `http://localhost:*` -- CLI automatic flow
- `http://127.0.0.1:*` -- CLI automatic flow
- `https://*.ainative.studio/*` -- first-party apps

If the client is registered in `oauth_clients`, its registered `redirect_uris` are checked by exact match instead.

The endpoint renders a login page. After the user authenticates, the server redirects to the `redirect_uri` with an authorization code:

```
http://localhost:8080/callback?code=<auth_code>&state=<state>
```

Authorization codes expire after **5 minutes** and are single-use.

### Step 3: Token Exchange

Exchange the authorization code for tokens. Accepts both form-encoded and JSON request bodies.

```
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<auth_code>
&redirect_uri=http://localhost:8080/callback
&client_id=<client_id>
&code_verifier=<code_verifier>
```

| Parameter | Required | Description |
|-----------|----------|-------------|
| `grant_type` | Yes | `authorization_code` |
| `code` | Yes | Authorization code from step 2 |
| `redirect_uri` | Yes | Must match the authorize request |
| `client_id` | Yes | Must match the authorize request |
| `code_verifier` | Yes | PKCE verifier from step 1 |

**Response (200 OK):**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2g...",
  "token_type": "bearer",
  "expires_in": 3600,
  "scope": "memory:read memory:write"
}
```

The server verifies the code verifier against the stored challenge using constant-time comparison (HMAC) to prevent timing attacks.

---

## 3. Token Refresh

Exchange a refresh token for a new access token. Refresh tokens are rotated on each use (the old token is invalidated).

```
POST /v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<client_id>
```

**Response (200 OK):**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "bmV3IHJlZnJlc2ggdG9r...",
  "token_type": "bearer",
  "expires_in": 3600,
  "scope": "memory:read memory:write"
}
```

---

## 4. Token Revocation

Revoke an access or refresh token (RFC 7009). Always returns 200 even for invalid tokens, per the specification.

```
POST /v1/oauth/revoke
Content-Type: application/json

{
  "token": "dGhpcyBpcyBhIHJlZnJlc2g...",
  "token_type_hint": "refresh_token"
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `token` | Yes | The token to revoke |
| `token_type_hint` | No | Optional hint: `access_token` or `refresh_token` |

**Response (200 OK):**

```json
{
  "status": "ok"
}
```

---

## Token Lifetimes

| Token Type | Lifetime |
|------------|----------|
| Authorization code | 5 minutes |
| Access token (JWT) | 1 hour |
| Refresh token | 30 days |

Access tokens are HS256-signed JWTs with the following claims:

| Claim | Description |
|-------|-------------|
| `sub` | User email or identifier |
| `scope` | Space-separated granted scopes |
| `type` | `oauth_access` |
| `exp` | Expiration timestamp |
| `iat` | Issued-at timestamp |

---

## OAuth Scopes (Authorization Server)

The authorization server recognizes these scopes at the `/oauth/authorize` endpoint:

| Scope | Description |
|-------|-------------|
| `user:profile` | Read user profile |
| `user:inference` | Run inference requests |
| `user:sessions` | Manage chat sessions |
| `user:sessions:claude_code` | CLI-specific session scope |
| `user:mcp_servers` | Manage MCP server connections |
| `user:file_upload` | Upload files |
| `org:create_api_key` | Create API keys via OAuth (CLI Console) |
| `openid` | OpenID Connect identity |
| `profile` | OIDC profile claims |
| `email` | OIDC email claim |
| `memory:read` | Read memory/vectors |
| `memory:write` | Write memory/vectors |
| `vector:search` | Search vector embeddings |
| `vector:write` | Write vector embeddings |
| `agent:read` | Read agent state |
| `agent:write` | Manage agents |

---

## Complete CLI Flow Example (Python)

```python
import secrets
import hashlib
import base64
import webbrowser
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
import requests

## 1. Register client (one-time)
reg = requests.post("https://api.ainative.studio:8000/oauth/clients/register", json={
    "client_name": "my-cli",
    "redirect_uris": ["http://localhost:9876/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "token_endpoint_auth_method": "none",
    "scope": "user:profile user:inference",
}).json()
client_id = reg["client_id"]

## 2. Generate PKCE pair
verifier = secrets.token_urlsafe(64)[:64]
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
state = secrets.token_urlsafe(16)

## 3. Open browser for authorization
auth_url = (
    f"https://api.ainative.studio:8000/oauth/authorize"
    f"?response_type=code&client_id={client_id}"
    f"&redirect_uri=http://localhost:9876/callback"
    f"&scope=user:profile user:inference"
    f"&state={state}&code_challenge={challenge}"
    f"&code_challenge_method=S256"
)
webbrowser.open(auth_url)

## 4. Receive callback on local server
auth_code = None

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        nonlocal auth_code
        params = parse_qs(urlparse(self.path).query)
        auth_code = params["code"][0]
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"Login successful. You can close this window.")

server = HTTPServer(("localhost", 9876), Handler)
server.handle_request()

## 5. Exchange code for tokens
tokens = requests.post("https://api.ainative.studio:8000/v1/oauth/token", data={
    "grant_type": "authorization_code",
    "code": auth_code,
    "redirect_uri": "http://localhost:9876/callback",
    "client_id": client_id,
    "code_verifier": verifier,
}).json()

print(f"Access token: {tokens['access_token'][:20]}...")
```

---

## Error Responses

| Status | Endpoint | Condition |
|--------|----------|-----------|
| 400 | `/oauth/authorize` | Invalid `response_type`, `code_challenge_method`, redirect URI, or scopes |
| 400 | `/v1/oauth/token` | Invalid or expired code, client_id mismatch, PKCE failure, unsupported grant_type |
| 400 | `/v1/oauth/token` | Invalid or expired refresh token |
| 422 | `/oauth/clients/register` | Validation error on client metadata |
| 500 | `/oauth/clients/register` | Database initialization or persistence failure |
