OAuth 2.0 SDK Helpers
The OpenPets SDK provides built-in OAuth 2.0 helpers that automate the entire authentication flow. These helpers handle:
- Starting a local callback server
- Opening the browser for user authentication
- Handling the OAuth callback automatically
- Exchanging the authorization code for tokens
- Saving tokens to
.pets/config.json
Quick Start
import {
startOAuthFlow,
refreshAndSaveTokens,
getOAuthStatus,
createOAuthTools,
type OAuthConfig
} from "openpets-sdk"
// Define your OAuth configuration
const oauthConfig: OAuthConfig = {
petId: "my-service",
authorizationUrl: "https://api.example.com/oauth/authorize",
tokenUrl: "https://api.example.com/oauth/token",
clientId: process.env.MY_SERVICE_CLIENT_ID!,
clientSecret: process.env.MY_SERVICE_CLIENT_SECRET,
scopes: ["read", "write"]
}
// Option 1: Use the complete OAuth flow
const result = await startOAuthFlow(oauthConfig)
if (result.success) {
console.log("Tokens saved!", result.tokens)
}
// Option 2: Create standard OAuth tools for your plugin
const oauthTools = createOAuthTools(oauthConfig)API Reference
OAuthConfig
Configuration object for OAuth flows:
interface OAuthConfig {
/** Pet identifier (e.g., "whoop", "github") */
petId: string
/** Environment variable prefix (defaults to petId.toUpperCase()) */
envPrefix?: string
/** OAuth authorization endpoint */
authorizationUrl: string
/** OAuth token endpoint */
tokenUrl: string
/** OAuth client ID */
clientId: string
/** OAuth client secret (optional for PKCE flows) */
clientSecret?: string
/** Requested scopes */
scopes: string[]
/** Custom redirect URI (default: auto-assigned localhost port) */
redirectUri?: string
/** Additional authorization URL parameters */
extraParams?: Record<string, string>
/** Timeout in milliseconds (default: 120000 = 2 minutes) */
timeout?: number
/** Port range for callback server (default: 3000-3100) */
portRange?: { start: number; end: number }
}startOAuthFlow(config)
Starts the complete OAuth flow. Opens a browser, handles the callback, exchanges code for tokens, and saves them.
const result = await startOAuthFlow(oauthConfig)
// Result type:
interface OAuthResult {
success: boolean
tokens?: {
access_token: string
token_type: string
expires_in?: number
refresh_token?: string
scope?: string
}
error?: string
message: string
}What it does:
- Finds an available port (3000-3100 by default)
- Starts a local HTTP server for the OAuth callback
- Generates a random state parameter for CSRF protection
- Opens the authorization URL in the default browser
- Waits for the user to authenticate (with timeout)
- Validates the state parameter
- Exchanges the authorization code for tokens
- Saves tokens to
.pets/config.json - Cleans up the callback server
refreshAndSaveTokens(config)
Refreshes the access token using a stored refresh token.
const result = await refreshAndSaveTokens(oauthConfig)
if (result.success) {
console.log("New access token:", result.tokens?.access_token)
}Requirements:
- A refresh token must exist in
.pets/config.jsonor environment variables - Client ID (and client secret for confidential clients) must be available
getOAuthStatus(config)
Checks the current OAuth configuration and token status.
const status = getOAuthStatus(oauthConfig)
// Returns:
{
hasClientCredentials: boolean // CLIENT_ID and CLIENT_SECRET are set
hasAccessToken: boolean // Access token is available
hasRefreshToken: boolean // Refresh token is available
canAutoRefresh: boolean // Can refresh tokens automatically
}createOAuthTools(config)
Creates a standard set of OAuth tool definitions for your plugin:
const oauthTools = createOAuthTools(oauthConfig)
// Creates three tools:
// - {petId}-authenticate: Complete OAuth flow
// - {petId}-refresh-token: Manual token refresh
// - {petId}-oauth-status: Check OAuth statusComplete Example
Here's how to implement OAuth in a pet plugin:
import { config as loadDotenv } from "dotenv"
import {
z, createPlugin, type ToolDefinition, createLogger, loadEnv,
startOAuthFlow, refreshAndSaveTokens, getOAuthStatus, type OAuthConfig
} from "openpets-sdk"
loadDotenv()
export const MyServicePlugin = async () => {
const logger = createLogger("my-service")
const env = loadEnv("my-service")
// Get OAuth credentials
const CLIENT_ID = env.MY_SERVICE_CLIENT_ID || process.env.MY_SERVICE_CLIENT_ID
const CLIENT_SECRET = env.MY_SERVICE_CLIENT_SECRET || process.env.MY_SERVICE_CLIENT_SECRET
const ACCESS_TOKEN = env.MY_SERVICE_ACCESS_TOKEN || process.env.MY_SERVICE_ACCESS_TOKEN
const REFRESH_TOKEN = env.MY_SERVICE_REFRESH_TOKEN || process.env.MY_SERVICE_REFRESH_TOKEN
const hasOAuthCredentials = !!(CLIENT_ID && CLIENT_SECRET)
const hasAccessToken = !!ACCESS_TOKEN
const hasRefreshToken = !!REFRESH_TOKEN
// Build OAuth config
const oauthConfig: OAuthConfig = {
petId: "my-service",
authorizationUrl: "https://api.myservice.com/oauth/authorize",
tokenUrl: "https://api.myservice.com/oauth/token",
clientId: CLIENT_ID || "",
clientSecret: CLIENT_SECRET,
scopes: ["read", "write", "offline_access"]
}
// OAuth tools
const oauthTools: ToolDefinition[] = [
{
name: "my-service-authenticate",
description: "Authenticate with MyService. Opens browser for OAuth login.",
schema: z.object({}),
async execute() {
if (!hasOAuthCredentials) {
return JSON.stringify({
success: false,
error: "Missing OAuth credentials",
message: "Set MY_SERVICE_CLIENT_ID and MY_SERVICE_CLIENT_SECRET first"
}, null, 2)
}
const result = await startOAuthFlow(oauthConfig)
return JSON.stringify({
success: result.success,
message: result.message,
...(result.tokens && {
expiresIn: result.tokens.expires_in,
hasRefreshToken: !!result.tokens.refresh_token
}),
...(result.error && { error: result.error })
}, null, 2)
}
},
{
name: "my-service-refresh-token",
description: "Refresh MyService access token.",
schema: z.object({}),
async execute() {
const result = await refreshAndSaveTokens(oauthConfig)
return JSON.stringify(result, null, 2)
}
},
{
name: "my-service-oauth-status",
description: "Check OAuth status for MyService.",
schema: z.object({}),
async execute() {
const status = getOAuthStatus(oauthConfig)
return JSON.stringify({
success: true,
status,
configured: status.hasAccessToken,
nextStep: !status.hasClientCredentials
? "Set MY_SERVICE_CLIENT_ID and MY_SERVICE_CLIENT_SECRET"
: !status.hasAccessToken
? "Run my-service-authenticate to log in"
: "Ready to use"
}, null, 2)
}
}
]
// API tools (only available when authenticated)
const apiTools: ToolDefinition[] = [
{
name: "my-service-list-items",
description: "List items from MyService",
schema: z.object({
limit: z.number().optional().describe("Max results to return")
}),
async execute(args) {
const response = await fetch("https://api.myservice.com/items", {
headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }
})
const data = await response.json()
return JSON.stringify(data, null, 2)
}
}
// ... more API tools
]
// Build tool list based on configuration state
let tools: ToolDefinition[] = []
// Always add OAuth tools if credentials are available
if (hasOAuthCredentials) {
tools = [...tools, ...oauthTools]
} else {
// Just add status tool so users know what's missing
tools.push(oauthTools[2])
}
// Add API tools only if we have an access token
if (hasAccessToken || (hasOAuthCredentials && hasRefreshToken)) {
tools = [...tools, ...apiTools]
}
return createPlugin(tools)
}
export default MyServicePluginToken Storage
Tokens are stored in .pets/config.json under petConfig.[petId]:
{
"petConfig": {
"my-service": {
"MY_SERVICE_ACCESS_TOKEN": "eyJ...",
"MY_SERVICE_REFRESH_TOKEN": "abc123..."
}
}
}The SDK automatically:
- Creates the config file if it doesn't exist
- Preserves existing configuration
- Uses the
envPrefixfor variable naming (defaults toPETID_)
Environment Variables
OAuth credentials can come from multiple sources (in priority order):
.pets/config.json- Managed by the SDK- Environment variables - Set in shell or
.envfile - Process environment - Set by the runtime
Example .env file:
MY_SERVICE_CLIENT_ID=your_client_id
MY_SERVICE_CLIENT_SECRET=your_client_secret
# Tokens are typically stored in .pets/config.json, not .envCustom Parameters
Some OAuth providers require additional parameters. Use extraParams:
const oauthConfig: OAuthConfig = {
petId: "custom-service",
authorizationUrl: "https://api.example.com/oauth/authorize",
tokenUrl: "https://api.example.com/oauth/token",
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
scopes: ["read", "write"],
extraParams: {
access_type: "offline", // Request refresh token
prompt: "consent", // Force consent screen
audience: "https://api.example.com" // API identifier
}
}Error Handling
The OAuth helpers return structured results for easy error handling:
const result = await startOAuthFlow(oauthConfig)
if (!result.success) {
// Handle error
console.error(result.error)
console.error(result.message)
return
}
// Use tokens
console.log(result.tokens?.access_token)Common errors:
- "No available port" - All ports in range are in use
- "OAuth timeout" - User didn't complete authentication in time
- "State mismatch" - CSRF protection triggered (possible attack)
- "Token exchange failed" - Server rejected the authorization code
- "No refresh token available" - Can't refresh without stored refresh token
Security Considerations
- Never commit credentials - Use
.envfiles and add to.gitignore - State parameter - The SDK automatically generates and validates CSRF state
- Local callback only - The callback server only listens on
127.0.0.1 - Automatic cleanup - Server shuts down immediately after callback
- Timeouts - Default 2-minute timeout prevents hung processes
Comparison with MCP OAuth
Some pets use mcp-remote for OAuth (e.g., Asana, Polar). Here's when to use each:
| Feature | SDK OAuth | MCP Remote OAuth |
|---|---|---|
| Direct API access | Yes | Through MCP |
| Token control | Full | Managed by MCP |
| Custom endpoints | Yes | Provider-specific |
| Offline refresh | Yes | Provider-specific |
| Setup complexity | Medium | Lower |
Use SDK OAuth when:
- You need direct API access
- The service doesn't have an MCP server
- You want full control over tokens
- You need custom OAuth parameters
Use MCP Remote when:
- The service has an official MCP server
- You want simpler setup
- MCP tools are sufficient for your needs
See Also
- Environment Variables -
loadEnv,setEnvdocumentation - Read-Only Mode - Restricting pets to read operations
- Best Practices - General pet development guidelines