Authentication¶
Penfield uses OAuth 2.1 for authentication, compliant with the MCP 2025-11-25 Authorization Specification.
Quick Reference: Which Method?¶
| Use Case | Method | Complexity |
|---|---|---|
| Getting started / testing | API Key Exchange | Simple |
| Server-to-server automation | API Key Exchange | Simple |
| CLI tool / headless device | Device Code Flow | Medium |
| Desktop/web app with browser | Authorization Code + PKCE | Standard |
All methods produce the same JWT tokens. Choose based on your use case.
Discovery Endpoints (MCP Compliant)¶
Penfield implements OAuth 2.0 metadata discovery per RFC 8414 and RFC 9728.
Authorization Server Metadata¶
Response:
{
"issuer": "https://auth.penfield.app",
"authorization_endpoint": "https://auth.penfield.app/oauth/authorize",
"token_endpoint": "https://auth.penfield.app/oauth/token",
"device_authorization_endpoint": "https://auth.penfield.app/oauth/device_authorization",
"registration_endpoint": "https://auth.penfield.app/oauth/register",
"token_endpoint_auth_methods_supported": ["none"],
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["read", "write", "profile", "offline_access"],
"response_modes_supported": ["query"],
"authorization_response_iss_parameter_supported": true,
"service_documentation": "https://www.penfield.app/docs",
"dpop_signing_alg_values_supported": ["RS256", "ES256"]
}
Note on
token_endpoint_auth_methods_supported: ["none"]: This is standard OAuth 2.1 for public clients. The value"none"means no client secret is required at the token endpoint. Security is provided by PKCE (Proof Key for Code Exchange) - thecode_verifierproves client identity without a shared secret. This is the recommended approach for native apps, SPAs, and CLI tools per OAuth 2.1 and RFC 7636.
Protected Resource Metadata (RFC 9728)¶
Response:
{
"resource": "https://auth.penfield.app",
"authorization_servers": ["https://auth.penfield.app"],
"resource_name": "Penfield Memory System",
"scopes_supported": ["read", "write", "openid", "profile", "offline_access"],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["HS256"],
"resource_documentation": "https://github.com/penfieldlabs/docs",
"resource_policy_uri": "https://portal.penfield.app/privacy",
"resource_tos_uri": "https://portal.penfield.app/terms"
}
Key Point: Always discover endpoints dynamically. Never hardcode OAuth URLs.
Scopes¶
Discover available scopes from the metadata endpoints:
# Get scopes from authorization server metadata
curl -s https://api.penfield.app/.well-known/oauth-authorization-server | \
python3 -c "import sys,json; print(json.load(sys.stdin)['scopes_supported'])"
Scopes:
| Scope | Description |
|---|---|
read |
Read memories, search |
write |
Create, update, delete memories |
profile |
Access user profile information |
offline_access |
Receive refresh tokens |
Note: Always check the discovery endpoint for the current list of supported scopes. The Protected Resource Metadata may list additional scopes like openid.
API Key Exchange¶
The simplest authentication method. Exchange an API key for JWT tokens.
When to use: - Getting started quickly - Server-to-server integrations - Scripts and automation
Exchange API Key for JWT¶
curl -X POST https://api.penfield.app/api/v2/auth/token \
-H "Authorization: Bearer tm_your_tenant_ak_your_key" \
-H "Content-Type: application/json"
Response:
{
"status": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 259200,
"tenant_id": "pf_...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
},
"meta": {
"timestamp": "2026-01-30T12:00:00Z",
"version": "2.0.0",
"request_id": "..."
}
}
Note:
tenant_idis your account/workspace identifier. All memories and data are scoped to this tenant.
Note: The response includes a refresh_token. Use it to obtain new tokens without re-exchanging your API key. See Refresh Tokens.
Verify Token¶
Device Code Flow (RFC 8628)¶
For CLI tools, headless applications, and devices without a browser.
When to use: - CLI tools - IoT devices - Any headless environment
Benefits:
- Supports refresh tokens with offline_access scope
- User authorizes on a separate device
- Follows RFC 8628 standard
Step 1: Discover Endpoints¶
DISCOVERY=$(curl -s https://api.penfield.app/.well-known/oauth-authorization-server)
DEVICE_AUTH_URL=$(echo $DISCOVERY | python3 -c "import sys,json; print(json.load(sys.stdin)['device_authorization_endpoint'])")
TOKEN_URL=$(echo $DISCOVERY | python3 -c "import sys,json; print(json.load(sys.stdin)['token_endpoint'])")
Step 2: Register Client (Optional)¶
If you don't have a client_id, use Dynamic Client Registration:
REG_URL=$(echo $DISCOVERY | python3 -c "import sys,json; print(json.load(sys.stdin)['registration_endpoint'])")
curl -X POST "$REG_URL" \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-cli-tool",
"redirect_uris": ["http://localhost:8080/callback"],
"grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
"token_endpoint_auth_method": "none",
"scope": "read write offline_access"
}'
Response:
Step 3: Request Device Code¶
curl -X POST "$DEVICE_AUTH_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "scope=read write offline_access"
Response:
{
"device_code": "GmRhmhcxhwAzkoRubiFzegLbSwE4SBAAqEOwmI2NODBxINzONPUBAQl7PUPSDIw",
"user_code": "WDJB-MJHT",
"verification_uri": "https://portal.penfield.app/device",
"verification_uri_complete": "https://portal.penfield.app/device?user_code=WDJB-MJHT",
"expires_in": 900,
"interval": 5
}
Step 4: User Authorization¶
Direct the user to: https://portal.penfield.app/device?user_code=WDJB-MJHT
Step 5: Poll for Token¶
Poll until user authorizes:
curl -X POST "$TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:device_code" \
-d "device_code=YOUR_DEVICE_CODE" \
-d "client_id=YOUR_CLIENT_ID"
Polling Responses:
| Error | Action |
|---|---|
authorization_pending |
Continue polling at interval seconds |
slow_down |
Increase poll interval by 5 seconds |
access_denied |
User denied - stop polling |
expired_token |
Device code expired - restart flow |
Success Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 259200,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "read write offline_access"
}
Note: refresh_token is only included if offline_access scope was requested. Check expires_in for actual token lifetime.
Authorization Code + PKCE¶
For desktop/web applications with browser redirect capability.
When to use: - Desktop applications - Web applications - Any app that can open a browser and handle redirects
Step 1: Discover Endpoints¶
DISCOVERY=$(curl -s https://api.penfield.app/.well-known/oauth-authorization-server)
AUTH_URL=$(echo $DISCOVERY | python3 -c "import sys,json; print(json.load(sys.stdin)['authorization_endpoint'])")
TOKEN_URL=$(echo $DISCOVERY | python3 -c "import sys,json; print(json.load(sys.stdin)['token_endpoint'])")
Step 2: Generate PKCE Parameters¶
# Generate code_verifier (43-128 characters, URL-safe)
CODE_VERIFIER=$(openssl rand -hex 32)
# Generate code_challenge (S256)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
# Generate state
STATE=$(openssl rand -hex 16)
Step 3: Authorization Request¶
Open browser to:
$AUTH_URL?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=http://localhost:8080/callback&scope=read+write+offline_access&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256&state=$STATE
MCP Compliance: Include the resource parameter to bind tokens to the target:
Step 4: Handle Callback¶
After user authorizes, browser redirects to:
Always verify state matches your original value.
Step 5: Exchange Code for Tokens¶
curl -X POST "$TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE" \
-d "client_id=YOUR_CLIENT_ID" \
-d "redirect_uri=http://localhost:8080/callback" \
-d "code_verifier=$CODE_VERIFIER"
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 259200,
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"scope": "read write offline_access"
}
Refresh Tokens¶
Refresh tokens allow obtaining new access tokens without re-authentication.
Requirements:
- OAuth flows: Request offline_access scope during initial authorization
- API Key Exchange: Returns refresh tokens automatically
Token Rotation (RFC 9700)¶
Penfield implements refresh token rotation. Each refresh returns a new refresh token and invalidates the old one.
Always store and use the latest refresh token.
Refresh Request (API Key Tokens)¶
For tokens obtained via API Key Exchange, use the API refresh endpoint:
curl -X POST https://api.penfield.app/api/v2/auth/refresh \
-H "Content-Type: application/json" \
-d '{"refresh_token": "YOUR_REFRESH_TOKEN"}'
Response:
{
"status": "success",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 259200,
"tenant_id": "pf_...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
},
"meta": {...}
}
Refresh Request (OAuth Tokens)¶
For tokens obtained via Device Code or Authorization Code flows, use the OAuth token endpoint:
TOKEN_URL=$(curl -s https://api.penfield.app/.well-known/oauth-authorization-server | python3 -c "import sys,json; print(json.load(sys.stdin)['token_endpoint'])")
curl -X POST "$TOKEN_URL" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=YOUR_REFRESH_TOKEN" \
-d "client_id=YOUR_CLIENT_ID"
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 259200,
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"scope": "read write offline_access"
}
Token Lifetimes¶
Token lifetimes are configured by Penfield and may vary by environment.
To check your actual token lifetime:
-
Decode the JWT (access token):
-
Check the
expclaim - expiration timestamp (Unix epoch) - Or use the
expires_invalue from the token response (seconds until expiration)
Typical values (subject to change):
| Token | Typical Lifetime |
|---|---|
| Access token | 24-72 hours |
| Refresh token | 7-30 days |
| Device code | 15 minutes |
Important: Do not hardcode these values. Always check
expires_inin the response or decode the JWT to get the actual expiration.
Using Tokens¶
Include the access token in the Authorization header:
Never include tokens in URLs or query parameters.
Error Handling¶
| Status | Error | Description |
|---|---|---|
| 401 | invalid_token |
Token expired or invalid |
| 401 | invalid_credentials |
Bad API key |
| 400 | invalid_grant |
Invalid authorization code or refresh token |
| 400 | invalid_request |
Missing required parameters |
Token Refresh Example¶
def make_request(url, token, refresh_token, client_id):
response = requests.get(url, headers={"Authorization": f"Bearer {token}"})
if response.status_code == 401:
# Token expired, refresh it
new_tokens = refresh_access_token(refresh_token, client_id)
token = new_tokens["access_token"]
refresh_token = new_tokens["refresh_token"] # Save the new refresh token!
# Retry request
response = requests.get(url, headers={"Authorization": f"Bearer {token}"})
return response, token, refresh_token
Environments¶
| Environment | API URL | Auth Server | Portal |
|---|---|---|---|
| Production | api.penfield.app |
auth.penfield.app |
portal.penfield.app |
| Development | api-dev.penfield.app |
auth-dev.penfield.app |
portal-dev.penfield.app |
Important: Always use the same environment consistently. Discovery from api.penfield.app returns auth.penfield.app endpoints.
Security Best Practices¶
- Never log tokens - Truncate in logs:
token[:10]... - Store tokens securely - Use encrypted storage, not plain files
- Refresh proactively - Refresh tokens before they expire (e.g., 5 min buffer)
- Validate state - Always verify the
stateparameter in PKCE flow - Use HTTPS only - All endpoints require HTTPS
- Rotate refresh tokens - Always use the newest refresh token