Skip to main content

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

{
"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"
}
FieldTypeRequiredDefaultDescription
client_namestringYes--Human-readable name (1-255 chars)
redirect_urisstring[]No[]Redirect URIs (must use http or https)
grant_typesstring[]No["authorization_code"]Supported: authorization_code, refresh_token, client_credentials
response_typesstring[]No["code"]Supported: code
scopestringNo""Space-separated scopes
token_endpoint_auth_methodstringNo"client_secret_basic"One of: client_secret_basic, client_secret_post, none
client_uristringNonullClient home page URL
logo_uristringNonullClient logo URL
contactsstring[]NonullContact email addresses
tos_uristringNonullTerms of service URL
policy_uristringNonullPrivacy policy URL

Available Scopes

ScopeDescription
memory:readRead from memory/vector stores
memory:writeWrite to memory/vector stores
vector:searchSearch vector embeddings
vector:writeWrite vector embeddings
agent:readRead agent state and history
agent:writeCreate and manage agents
openidOpenID Connect identity
profileUser profile information
emailUser email address

Response (201 Created)

{
"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:

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
ParameterRequiredDescription
response_typeYesMust be code
client_idYesFrom client registration
redirect_uriYesMust match a registered URI or the static allowlist
scopeYesSpace-separated scopes
stateRecommendedCSRF protection; returned unchanged in the callback
code_challengeYesS256 PKCE challenge
code_challenge_methodYesMust 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>
ParameterRequiredDescription
grant_typeYesauthorization_code
codeYesAuthorization code from step 2
redirect_uriYesMust match the authorize request
client_idYesMust match the authorize request
code_verifierYesPKCE verifier from step 1

Response (200 OK):

{
"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):

{
"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"
}
FieldRequiredDescription
tokenYesThe token to revoke
token_type_hintNoOptional hint: access_token or refresh_token

Response (200 OK):

{
"status": "ok"
}

Token Lifetimes

Token TypeLifetime
Authorization code5 minutes
Access token (JWT)1 hour
Refresh token30 days

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

ClaimDescription
subUser email or identifier
scopeSpace-separated granted scopes
typeoauth_access
expExpiration timestamp
iatIssued-at timestamp

OAuth Scopes (Authorization Server)

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

ScopeDescription
user:profileRead user profile
user:inferenceRun inference requests
user:sessionsManage chat sessions
user:sessions:claude_codeCLI-specific session scope
user:mcp_serversManage MCP server connections
user:file_uploadUpload files
org:create_api_keyCreate API keys via OAuth (CLI Console)
openidOpenID Connect identity
profileOIDC profile claims
emailOIDC email claim
memory:readRead memory/vectors
memory:writeWrite memory/vectors
vector:searchSearch vector embeddings
vector:writeWrite vector embeddings
agent:readRead agent state
agent:writeManage agents

Complete CLI Flow Example (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

StatusEndpointCondition
400/oauth/authorizeInvalid response_type, code_challenge_method, redirect URI, or scopes
400/v1/oauth/tokenInvalid or expired code, client_id mismatch, PKCE failure, unsupported grant_type
400/v1/oauth/tokenInvalid or expired refresh token
422/oauth/clients/registerValidation error on client metadata
500/oauth/clients/registerDatabase initialization or persistence failure