Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

OAuth2 Server

Pierre includes a standards-compliant oauth2 authorization server for secure mcp client authentication.

Features

  • authorization code flow with pkce (s256 only)
  • dynamic client registration (rfc 7591)
  • server-side state validation for csrf protection
  • argon2id client secret hashing
  • multi-tenant isolation
  • refresh token rotation
  • jwt-based access tokens

Quick Start

1. Register OAuth2 Client

curl -X POST http://localhost:8081/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["https://example.com/callback"],
    "client_name": "My MCP Client",
    "grant_types": ["authorization_code"],
    "response_types": ["code"]
  }'

Response:

{
  "client_id": "mcp_client_abc123",
  "client_secret": "secret_xyz789",
  "client_id_issued_at": 1640000000,
  "redirect_uris": ["https://example.com/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"]
}

important: save client_secret immediately. Cannot be retrieved later.

2. Generate PKCE Challenge

import secrets
import hashlib
import base64

# generate code verifier (43-128 characters)
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')

# generate code challenge (s256)
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')

# generate state (csrf protection)
state = secrets.token_urlsafe(32)

# store code_verifier and state in session
session['code_verifier'] = code_verifier
session['oauth_state'] = state

3. Initiate Authorization

Redirect user to authorization endpoint:

https://pierre.example.com/oauth2/authorize?
  response_type=code&
  client_id=mcp_client_abc123&
  redirect_uri=https://example.com/callback&
  state=<random_state>&
  code_challenge=<pkce_challenge>&
  code_challenge_method=S256&
  scope=read:activities write:goals

User will authenticate and authorize. Pierre redirects to callback with authorization code:

https://example.com/callback?
  code=auth_code_xyz&
  state=<same_random_state>

4. Validate State and Exchange Code

# validate state parameter (csrf protection)
received_state = request.args.get('state')
stored_state = session.pop('oauth_state', None)

if not received_state or received_state != stored_state:
    return "csrf attack detected", 400

# exchange authorization code for tokens
code = request.args.get('code')
code_verifier = session.pop('code_verifier')
curl -X POST http://localhost:8081/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=auth_code_xyz" \
  -d "redirect_uri=https://example.com/callback" \
  -d "client_id=mcp_client_abc123" \
  -d "client_secret=secret_xyz789" \
  -d "code_verifier=<stored_code_verifier>"

Response:

{
  "access_token": "jwt_access_token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "refresh_token_abc",
  "scope": "read:activities write:goals"
}

5. Use Access Token

curl -H "Authorization: Bearer jwt_access_token" \
  http://localhost:8081/mcp

Client Registration

Register New Client

Endpoint: POST /oauth2/register

Required fields:

  • redirect_uris - array of callback urls (https required except localhost)

Optional fields:

  • client_name - display name
  • client_uri - client homepage url
  • grant_types - defaults to ["authorization_code"]
  • response_types - defaults to ["code"]
  • scope - space-separated scope list

Redirect URI Validation

Pierre enforces strict redirect uri validation:

allowed:

  • https:// urls (production)
  • http://localhost:* (development)
  • http://127.0.0.1:* (development)
  • urn:ietf:wg:oauth:2.0:oob (out-of-band for native apps)

rejected:

  • http:// non-localhost urls
  • urls with fragments (#)
  • wildcard domains (*.example.com)
  • malformed urls

Example Registrations

web application:

curl -X POST http://localhost:8081/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["https://app.example.com/auth/callback"],
    "client_name": "Example Web App",
    "client_uri": "https://app.example.com",
    "scope": "read:activities read:athlete"
  }'

native application:

curl -X POST http://localhost:8081/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["http://localhost:8080/callback"],
    "client_name": "Example Desktop App",
    "scope": "read:activities write:goals"
  }'

Authorization Flow

Step 1: Authorization Request

Build authorization url with required parameters:

from urllib.parse import urlencode

params = {
    'response_type': 'code',
    'client_id': client_id,
    'redirect_uri': redirect_uri,
    'state': state,                    # required for csrf protection
    'code_challenge': code_challenge,  # required for pkce
    'code_challenge_method': 'S256',   # only s256 supported
    'scope': 'read:activities write:goals'  # optional
}

auth_url = f"https://pierre.example.com/oauth2/authorize?{urlencode(params)}"

Redirect user to auth_url.

Step 2: User Authentication

If user not logged in, pierre displays login form. After successful login, shows authorization consent screen.

Step 3: Authorization Callback

Pierre redirects to your redirect_uri with authorization code:

https://example.com/callback?code=<auth_code>&state=<state>

Error response (if user denies):

https://example.com/callback?error=access_denied&error_description=User+denied+authorization

Step 4: Token Exchange

Exchange authorization code for access token:

curl -X POST http://localhost:8081/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<auth_code>" \
  -d "redirect_uri=<same_redirect_uri>" \
  -d "client_id=<client_id>" \
  -d "client_secret=<client_secret>" \
  -d "code_verifier=<pkce_verifier>"

important: authorization codes expire in 10 minutes and are single-use.

Token Management

Access Tokens

Jwt-based tokens with 1-hour expiration (configurable).

Claims include:

  • sub - user id
  • email - user email
  • tenant_id - tenant identifier
  • scope - granted scopes
  • exp - expiration timestamp

Refresh Tokens

Use refresh token to obtain new access token without re-authentication:

curl -X POST http://localhost:8081/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=<refresh_token>" \
  -d "client_id=<client_id>" \
  -d "client_secret=<client_secret>"

Response:

{
  "access_token": "new_jwt_access_token",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "new_refresh_token",
  "scope": "read:activities write:goals"
}

refresh token rotation: pierre issues new refresh token with each refresh request. Old refresh token is revoked.

Token Validation

Validate access token and optionally refresh if expired:

curl -X POST http://localhost:8081/oauth2/validate-and-refresh \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "refresh_token": "optional_refresh_token"
  }'

Responses:

valid token:

{
  "status": "valid",
  "expires_in": 1800
}

token refreshed:

{
  "status": "refreshed",
  "access_token": "new_jwt_token",
  "refresh_token": "new_refresh_token",
  "token_type": "Bearer"
}

invalid token:

{
  "status": "invalid",
  "reason": "token expired",
  "requires_full_reauth": true
}

Security Features

PKCE (Proof Key for Code Exchange)

Pierre requires pkce for all authorization code flows.

supported methods:

  • S256 (sha256) - required

rejected methods:

  • plain - insecure, not supported

implementation:

  1. Generate random code_verifier (43-128 characters)
  2. Compute code_challenge = base64url(sha256(code_verifier))
  3. Send code_challenge in authorization request
  4. Send code_verifier in token exchange
  5. Pierre validates sha256(code_verifier) == code_challenge

Prevents authorization code interception attacks.

State Parameter Validation

Pierre implements defense-in-depth csrf protection with server-side state validation.

client requirements:

  1. Generate cryptographically random state (≥128 bits entropy)
  2. Store state in session before authorization request
  3. Include state in authorization request
  4. Validate state matches in callback

server behavior:

  1. Stores state with 10-minute expiration
  2. Binds state to client_id and user
  3. Validates state on callback
  4. Marks state as used (single-use)
  5. Rejects expired, used, or mismatched states

example implementation:

import secrets

# before authorization
state = secrets.token_urlsafe(32)
session['oauth_state'] = state

# in callback
received_state = request.args.get('state')
stored_state = session.pop('oauth_state', None)

if not received_state or received_state != stored_state:
    abort(400, "invalid state - possible csrf attack")

Client Secret Hashing

Client secrets hashed with argon2id (memory-hard algorithm resistant to gpu attacks).

verification:

# validate client credentials
curl -X POST http://localhost:8081/oauth2/token \
  -d "client_id=<id>" \
  -d "client_secret=<secret>" \
  ...

Pierre verifies secret using constant-time comparison to prevent timing attacks.

Multi-tenant Isolation

All oauth artifacts (codes, tokens, states) bound to tenant_id. Cross-tenant access prevented at database layer.

Scopes

Pierre supports fine-grained permission control via oauth scopes.

Available Scopes

fitness data:

  • read:activities - read activity data
  • write:activities - create/update activities
  • read:athlete - read athlete profile
  • write:athlete - update athlete profile

goals and analytics:

  • read:goals - read fitness goals
  • write:goals - create/update goals
  • read:analytics - access analytics data

administrative:

  • admin:users - manage users
  • admin:system - system administration

Requesting Scopes

Include in authorization request:

/oauth2/authorize?
  ...
  scope=read:activities read:athlete write:goals

Scope Validation

Pierre validates requested scopes against client’s registered scopes. Access tokens include granted scopes in jwt claims.

Error Handling

Authorization Errors

Returned as query parameters in redirect:

https://example.com/callback?
  error=invalid_request&
  error_description=missing+code_challenge&
  state=<state>

common errors:

  • invalid_request - missing or invalid parameters
  • unauthorized_client - client not authorized for this flow
  • access_denied - user denied authorization
  • unsupported_response_type - response_type not supported
  • invalid_scope - requested scope invalid or not allowed
  • server_error - internal server error

Token Errors

Returned as json in response body:

{
  "error": "invalid_grant",
  "error_description": "authorization code expired",
  "error_uri": "https://datatracker.ietf.org/doc/html/rfc6749#section-5.2"
}

common errors:

  • invalid_request - malformed request
  • invalid_client - client authentication failed
  • invalid_grant - code expired, used, or invalid
  • unauthorized_client - client not authorized
  • unsupported_grant_type - grant type not supported

Common Integration Patterns

Web Application Flow

  1. User clicks “connect with pierre”
  2. App redirects to pierre authorization endpoint
  3. User logs in (if needed) and approves
  4. Pierre redirects back with authorization code
  5. App exchanges code for tokens (server-side)
  6. App stores tokens securely (encrypted database)
  7. App uses access token for api requests
  8. App refreshes token before expiration

Native Application Flow

  1. App opens system browser to authorization url
  2. User authenticates and approves
  3. Browser redirects to http://localhost:port/callback
  4. App’s local server receives callback
  5. App exchanges code for tokens
  6. App stores tokens securely (os keychain)

Single Page Application (SPA) Flow

recommended: use authorization code flow with pkce:

  1. Spa redirects to pierre authorization endpoint
  2. Pierre redirects back with authorization code
  3. Spa exchanges code for tokens via backend proxy
  4. Backend stores refresh token
  5. Backend returns short-lived access token to spa
  6. Spa uses access token for api requests
  7. Spa requests new access token via backend when expired

not recommended: implicit flow (deprecated)

Troubleshooting

Authorization Code Expired

symptom: invalid_grant error when exchanging code

solution: authorization codes expire in 10 minutes. Restart authorization flow.

PKCE Validation Failed

symptom: invalid_grant: pkce verification failed

solutions:

  • ensure code_verifier sent in token request matches original
  • verify code_challenge computed as base64url(sha256(code_verifier))
  • check no extra padding (=) in base64url encoding

State Validation Failed

symptom: invalid_grant: invalid state parameter

solutions:

  • ensure state sent in callback matches original request
  • check state not expired (10-minute ttl)
  • verify state not reused (single-use)
  • confirm state stored in user session before authorization

Redirect URI Mismatch

symptom: invalid_request: redirect_uri mismatch

solutions:

  • redirect_uri in authorization request must exactly match registration
  • redirect_uri in token request must match authorization request
  • https required for non-localhost urls

Client Authentication Failed

symptom: invalid_client

solutions:

  • verify client_id correct
  • verify client_secret correct (case-sensitive)
  • ensure client_secret not expired
  • check client not deleted

Refresh Token Revoked

symptom: invalid_grant: refresh token revoked or expired

solutions:

  • refresh tokens expire after 30 days of inactivity
  • old refresh tokens revoked after successful refresh (rotation)
  • restart authorization flow to obtain new tokens

Configuration

Token Lifetimes

Pierre currently uses fixed lifetimes for OAuth2 artifacts (configured in code, not via environment variables):

  • Authorization codes: 10 minutes (single-use)
  • Access tokens: 1 hour
  • Refresh tokens: 30 days
  • State parameters: 10 minutes

Changing these values requires a code change in the OAuth2 server configuration (see src/oauth2_server/ and src/constants/).

See Also