Skip to content

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

typescript
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:

typescript
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.

typescript
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:

  1. Finds an available port (3000-3100 by default)
  2. Starts a local HTTP server for the OAuth callback
  3. Generates a random state parameter for CSRF protection
  4. Opens the authorization URL in the default browser
  5. Waits for the user to authenticate (with timeout)
  6. Validates the state parameter
  7. Exchanges the authorization code for tokens
  8. Saves tokens to .pets/config.json
  9. Cleans up the callback server

refreshAndSaveTokens(config)

Refreshes the access token using a stored refresh token.

typescript
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.json or environment variables
  • Client ID (and client secret for confidential clients) must be available

getOAuthStatus(config)

Checks the current OAuth configuration and token status.

typescript
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:

typescript
const oauthTools = createOAuthTools(oauthConfig)

// Creates three tools:
// - {petId}-authenticate: Complete OAuth flow
// - {petId}-refresh-token: Manual token refresh
// - {petId}-oauth-status: Check OAuth status

Complete Example

Here's how to implement OAuth in a pet plugin:

typescript
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 MyServicePlugin

Token Storage

Tokens are stored in .pets/config.json under petConfig.[petId]:

json
{
  "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 envPrefix for variable naming (defaults to PETID_)

Environment Variables

OAuth credentials can come from multiple sources (in priority order):

  1. .pets/config.json - Managed by the SDK
  2. Environment variables - Set in shell or .env file
  3. Process environment - Set by the runtime

Example .env file:

bash
MY_SERVICE_CLIENT_ID=your_client_id
MY_SERVICE_CLIENT_SECRET=your_client_secret
# Tokens are typically stored in .pets/config.json, not .env

Custom Parameters

Some OAuth providers require additional parameters. Use extraParams:

typescript
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:

typescript
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

  1. Never commit credentials - Use .env files and add to .gitignore
  2. State parameter - The SDK automatically generates and validates CSRF state
  3. Local callback only - The callback server only listens on 127.0.0.1
  4. Automatic cleanup - Server shuts down immediately after callback
  5. 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:

FeatureSDK OAuthMCP Remote OAuth
Direct API accessYesThrough MCP
Token controlFullManaged by MCP
Custom endpointsYesProvider-specific
Offline refreshYesProvider-specific
Setup complexityMediumLower

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