OAuth Flow
The CLI implements the OAuth 2.0 Authorization Code flow with PKCE for secure authentication with health providers.
Flow Overview
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ CLI │ │ Browser │ │ WHOOP │ │ Callback │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ Generate PKCE │ │ │
│ code_verifier │ │ │
│ & code_challenge │ │
│ │ │ │
│ Start callback │ │ │
│ server │ │ │
│ │ │ │
│ Open auth URL ─┼───────────────▶│ │
│ │ │ │
│ │ User authorizes│ │
│ │◀───────────────│ │
│ │ │ │
│ │ Redirect ──────┼───────────────▶│
│ │ │ │
│◀───────────────┼────────────────┼── Auth code ───│
│ │ │ │
│ Exchange code ─┼───────────────▶│ │
│ + code_verifier│ │ │
│ │ │ │
│◀───────────────┼── Tokens ──────│ │
│ │ │ │
│ Store tokens │ │ │
│ in .env file │ │ │
│ │ │ │
Implementation Details
1. PKCE Generation
The CLI generates a cryptographically secure code verifier and challenge:
import crypto from 'crypto';
function generatePKCE() {
// Generate 32 random bytes, encode as base64url
const verifier = crypto.randomBytes(32)
.toString('base64url');
// SHA-256 hash the verifier, encode as base64url
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
2. Authorization URL
The CLI constructs the authorization URL with required parameters:
function buildAuthUrl(codeChallenge: string, port: number): string {
const params = new URLSearchParams({
client_id: WHOOP_CLIENT_ID,
redirect_uri: `http://localhost:${port}/callback`,
response_type: 'code',
scope: 'read:profile read:sleep read:recovery read:workout read:cycles offline',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: crypto.randomBytes(16).toString('hex'),
});
return `https://api.prod.whoop.com/oauth/oauth2/auth?${params}`;
}
3. Callback Server
A temporary HTTP server captures the OAuth callback:
import http from 'http';
async function startCallbackServer(port: number): Promise<{ code: string; state: string }> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url!, `http://localhost:${port}`);
if (url.pathname === '/callback') {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end('<h1>Authorization Failed</h1><p>You can close this window.</p>');
reject(new Error(error));
} else if (code && state) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Authorization Successful</h1><p>You can close this window.</p>');
resolve({ code, state });
}
server.close();
}
});
server.listen(port);
// Timeout after 5 minutes
setTimeout(() => {
server.close();
reject(new Error('Authorization timeout'));
}, 300000);
});
}
4. Token Exchange
The CLI exchanges the authorization code for tokens:
async function exchangeCode(
code: string,
codeVerifier: string,
redirectUri: string
): Promise<TokenResponse> {
const response = await fetch('https://api.prod.whoop.com/oauth/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: WHOOP_CLIENT_ID,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
return response.json();
}
5. Token Storage
Tokens are stored in a local .env file:
import fs from 'fs';
import path from 'path';
function storeTokens(tokens: TokenResponse): void {
const envPath = path.join(process.cwd(), '.env');
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000).toISOString();
const envContent = `
WHOOP_ACCESS_TOKEN=${tokens.access_token}
WHOOP_REFRESH_TOKEN=${tokens.refresh_token}
WHOOP_TOKEN_EXPIRES_AT=${expiresAt}
`.trim();
fs.writeFileSync(envPath, envContent);
}
Token Refresh
The MCP server automatically refreshes expired tokens:
async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
const response = await fetch('https://api.prod.whoop.com/oauth/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: WHOOP_CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
throw new Error('Token refresh failed');
}
return response.json();
}
Security Considerations
PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks:
- The
code_verifiernever leaves the CLI - The
code_challengeis sent to the authorization server - During token exchange, the server verifies the verifier matches the challenge
Local Callback
The callback server:
- Only listens on localhost (not externally accessible)
- Uses a random
stateparameter to prevent CSRF - Shuts down immediately after receiving the callback
- Times out after 5 minutes
Token Storage
Tokens are stored in plaintext in .env. For production deployments:
- Ensure
.envis in.gitignore - Consider using a secrets manager
- The cloud service uses encrypted storage
WHOOP Developer Account
To use the CLI, you need a WHOOP Developer account:
- Go to developer.whoop.com
- Create an application
- Get your Client ID
- Set redirect URI to
http://localhost:3000/callback
See the WHOOP Developer Account guide for detailed steps.
Troubleshooting
Port Already in Use
Error: listen EADDRINUSE: address already in use :::3000
Solution: Use a different port:
wellpipe auth whoop --port 3001
Callback Not Received
Possible causes:
- Browser blocked the redirect
- Firewall blocking localhost
- Using a different port than configured
Token Refresh Failed
If refresh fails, you need to re-authenticate:
wellpipe auth whoop