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 nameclient_uri- client homepage urlgrant_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 idemail- user emailtenant_id- tenant identifierscope- granted scopesexp- 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:
- Generate random
code_verifier(43-128 characters) - Compute
code_challenge = base64url(sha256(code_verifier)) - Send
code_challengein authorization request - Send
code_verifierin token exchange - 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:
- Generate cryptographically random state (≥128 bits entropy)
- Store state in session before authorization request
- Include state in authorization request
- Validate state matches in callback
server behavior:
- Stores state with 10-minute expiration
- Binds state to client_id and user
- Validates state on callback
- Marks state as used (single-use)
- 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 datawrite:activities- create/update activitiesread:athlete- read athlete profilewrite:athlete- update athlete profile
goals and analytics:
read:goals- read fitness goalswrite:goals- create/update goalsread:analytics- access analytics data
administrative:
admin:users- manage usersadmin: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 parametersunauthorized_client- client not authorized for this flowaccess_denied- user denied authorizationunsupported_response_type- response_type not supportedinvalid_scope- requested scope invalid or not allowedserver_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 requestinvalid_client- client authentication failedinvalid_grant- code expired, used, or invalidunauthorized_client- client not authorizedunsupported_grant_type- grant type not supported
Common Integration Patterns
Web Application Flow
- User clicks “connect with pierre”
- App redirects to pierre authorization endpoint
- User logs in (if needed) and approves
- Pierre redirects back with authorization code
- App exchanges code for tokens (server-side)
- App stores tokens securely (encrypted database)
- App uses access token for api requests
- App refreshes token before expiration
Native Application Flow
- App opens system browser to authorization url
- User authenticates and approves
- Browser redirects to
http://localhost:port/callback - App’s local server receives callback
- App exchanges code for tokens
- App stores tokens securely (os keychain)
Single Page Application (SPA) Flow
recommended: use authorization code flow with pkce:
- Spa redirects to pierre authorization endpoint
- Pierre redirects back with authorization code
- Spa exchanges code for tokens via backend proxy
- Backend stores refresh token
- Backend returns short-lived access token to spa
- Spa uses access token for api requests
- 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_verifiersent 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
- authentication - jwt and api key authentication
- protocols - fitness provider integrations
- configuration - server configuration