Skip to main content
Glama
monuit
by monuit
handler.ts9.06 kB
import crypto from 'crypto'; import axios from 'axios'; import { Request, Response } from 'express'; import { OAuthTokens } from '../oura/types.js'; import { logger } from '../utils/logger.js'; import { saveTokens, loadTokens, clearTokens, isAccessTokenValid, isExpiringSoon, hasRefreshToken, } from './tokens.js'; // OAuth endpoints const OURA_AUTH_URL = 'https://cloud.ouraring.com/oauth/authorize'; const OURA_TOKEN_URL = 'https://api.ouraring.com/oauth/token'; // PKCE state storage (in-memory for simplicity) // Generous 1 hour timeout for AI agents that may take time to complete OAuth flow const STATE_EXPIRATION_MS = 60 * 60 * 1000; // 1 hour const pkceState = new Map<string, { codeVerifier: string; state: string; expiresAt: number }>(); // Cleanup expired PKCE states periodically setInterval(() => { const now = Date.now(); for (const [state, data] of pkceState.entries()) { if (now > data.expiresAt) { pkceState.delete(state); } } }, 5 * 60 * 1000); // Cleanup every 5 minutes /** * Generates PKCE code verifier and challenge */ function generatePKCE(): { codeVerifier: string; codeChallenge: string } { const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); return { codeVerifier, codeChallenge }; } /** * Generates a random state string for CSRF protection */ function generateState(): string { return crypto.randomBytes(32).toString('hex'); } /** * Initiates OAuth2 authorization flow */ export function handleAuthorize(_req: Request, res: Response): void { const { codeVerifier, codeChallenge } = generatePKCE(); const state = generateState(); // Store PKCE state with expiration pkceState.set(state, { codeVerifier, state, expiresAt: Date.now() + STATE_EXPIRATION_MS }); const clientId = process.env.OURA_CLIENT_ID; const redirectUri = process.env.OURA_REDIRECT_URI; if (!clientId || !redirectUri) { res.status(500).send('Server configuration error: Missing OAuth credentials'); return; } const scopes = [ 'email', 'personal', 'daily', 'heartrate', 'workout', 'tag', 'session', 'spo2', ].join(' '); const authUrl = new URL(OURA_AUTH_URL); authUrl.searchParams.append('client_id', clientId); authUrl.searchParams.append('redirect_uri', redirectUri); authUrl.searchParams.append('response_type', 'code'); authUrl.searchParams.append('scope', scopes); authUrl.searchParams.append('state', state); authUrl.searchParams.append('code_challenge', codeChallenge); authUrl.searchParams.append('code_challenge_method', 'S256'); logger.info('Redirecting to Oura authorization page'); res.redirect(authUrl.toString()); } /** * Handles OAuth2 callback after user authorization */ export async function handleCallback(req: Request, res: Response): Promise<void> { const { code, state, error } = req.query; if (error) { logger.error('Authorization error:', error); res.status(400).send(`Authorization failed: ${error}`); return; } if (!code || !state || typeof code !== 'string' || typeof state !== 'string') { res.status(400).send('Invalid callback parameters'); return; } // Verify state and retrieve PKCE verifier const storedPKCE = pkceState.get(state); if (!storedPKCE) { res.status(400).send('Invalid state parameter (CSRF protection)'); return; } // Check if state has expired if (Date.now() > storedPKCE.expiresAt) { pkceState.delete(state); res.status(400).send('OAuth state expired. Please restart the authorization flow.'); return; } pkceState.delete(state); // Clean up try { const tokens = await exchangeCodeForTokens(code, storedPKCE.codeVerifier); await saveTokens(tokens); logger.info('Authorization successful, tokens saved'); res.send(` <!DOCTYPE html> <html> <head> <title>Oura Authorization Success</title> <style> body { font-family: system-ui; max-width: 600px; margin: 50px auto; padding: 20px; } .success { color: #10b981; font-size: 24px; margin-bottom: 20px; } .info { background: #f3f4f6; padding: 15px; border-radius: 8px; } </style> </head> <body> <div class="success">✓ Authorization Successful!</div> <div class="info"> <p>Your Oura Ring has been connected successfully.</p> <p>You can now close this window and use the MCP server.</p> </div> </body> </html> `); } catch (error) { logger.error('Token exchange failed:', error); res.status(500).send(`Token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Exchanges authorization code for access and refresh tokens */ async function exchangeCodeForTokens( code: string, codeVerifier: string ): Promise<OAuthTokens> { const clientId = process.env.OURA_CLIENT_ID; const clientSecret = process.env.OURA_CLIENT_SECRET; const redirectUri = process.env.OURA_REDIRECT_URI; if (!clientId || !clientSecret || !redirectUri) { throw new Error('Missing OAuth credentials in environment'); } const params = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier, }); try { const response = await axios.post(OURA_TOKEN_URL, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const { access_token, refresh_token, expires_in, token_type, scope } = response.data; return { access_token, refresh_token, expires_at: Date.now() + expires_in * 1000, token_type, scope, }; } catch (error) { if (axios.isAxiosError(error)) { throw new Error(`OAuth token exchange failed: ${error.response?.data?.error || error.message}`); } throw error; } } /** * Refreshes the access token using refresh token */ export async function refreshAccessToken(refreshToken: string): Promise<OAuthTokens> { const clientId = process.env.OURA_CLIENT_ID; const clientSecret = process.env.OURA_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error('Missing OAuth credentials in environment'); } const params = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret, }); try { logger.info('Refreshing access token'); const response = await axios.post(OURA_TOKEN_URL, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); const { access_token, refresh_token, expires_in, token_type, scope } = response.data; const tokens: OAuthTokens = { access_token, refresh_token: refresh_token || refreshToken, // Use new refresh token if provided expires_at: Date.now() + expires_in * 1000, token_type, scope, }; await saveTokens(tokens); logger.info('Access token refreshed successfully'); return tokens; } catch (error) { if (axios.isAxiosError(error)) { logger.error('Token refresh failed:', error.response?.data); throw new Error(`Token refresh failed: ${error.response?.data?.error || error.message}`); } throw error; } } /** * Gets a valid access token, refreshing if necessary */ export async function getValidAccessToken(): Promise<string> { let tokens = await loadTokens(); if (!tokens) { throw new Error('No OAuth tokens found. Please authenticate first.'); } // Check if token is valid and not expiring soon if (isAccessTokenValid(tokens) && !isExpiringSoon(tokens)) { return tokens.access_token; } // Try to refresh the token if (hasRefreshToken(tokens)) { try { tokens = await refreshAccessToken(tokens.refresh_token); // Save the refreshed tokens to disk await saveTokens(tokens); logger.info('Successfully refreshed and saved OAuth tokens'); return tokens.access_token; } catch (error) { logger.error('Failed to refresh token:', error); throw new Error('Failed to refresh access token. Please re-authenticate.'); } } throw new Error('Access token expired and no refresh token available. Please re-authenticate.'); } /** * Gets OAuth connection status */ export async function getOAuthStatus(): Promise<{ connected: boolean; expiresAt?: number; scope?: string; }> { const tokens = await loadTokens(); if (!tokens) { return { connected: false }; } return { connected: isAccessTokenValid(tokens), expiresAt: tokens.expires_at, scope: tokens.scope, }; } /** * Disconnects OAuth (clears tokens) */ export async function disconnectOAuth(): Promise<void> { await clearTokens(); logger.info('Disconnected successfully'); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/monuit/oura-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server