// OAuth2 CredentialAdapter. Implements the standard refresh_token + revoke // flow against any provider whose token endpoint follows the OAuth2 RFC. // // Provider-specific URLs and client credentials come from the provider's // manifest via ProviderOAuthConfig — Wahoo, future Strava/Garmin/Coros // share this adapter and supply their own endpoints. // // The adapter does not write to the database; it returns refreshed // credentials for ConnectedServiceManager to persist. import { NeedsRelinkError, type CredentialAdapter, type OAuthCredentials, } from "../types.ts"; // Refresh credentials slightly before they actually expire so a token in // the middle of a long-running operation doesn't tip over mid-request. const REFRESH_SKEW_MS = 60_000; function parseExpiresAt(iso: string): Date { const d = new Date(iso); if (isNaN(d.getTime())) { throw new Error(`OAuth credentials have invalid expires_at: ${iso}`); } return d; } export const oauthCredentialAdapter: CredentialAdapter = { isExpired(creds) { return parseExpiresAt(creds.expires_at).getTime() - Date.now() < REFRESH_SKEW_MS; }, async refresh(creds, providerConfig): Promise { const resp = await fetch(providerConfig.tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: providerConfig.clientId, client_secret: providerConfig.clientSecret, grant_type: "refresh_token", refresh_token: creds.refresh_token, }).toString(), }); if (!resp.ok) { const text = await resp.text().catch(() => ""); // 4xx on refresh = revoked / invalidated refresh token. Caller must re-link. // 5xx = transient; surface as a regular Error so caller can retry the operation. if (resp.status >= 400 && resp.status < 500) { throw new NeedsRelinkError( `OAuth refresh rejected (${resp.status}): ${text || "no body"}`, ); } throw new Error(`OAuth refresh failed (${resp.status}): ${text}`); } const data = (await resp.json()) as { access_token: string; refresh_token?: string; expires_in: number; }; return { access_token: data.access_token, // Some providers omit refresh_token on refresh (it stays the same). refresh_token: data.refresh_token ?? creds.refresh_token, expires_at: new Date(Date.now() + data.expires_in * 1000).toISOString(), }; }, async revoke(creds, providerConfig) { if (!providerConfig.revokeUrl) return; // Best-effort. Caller deletes the local row regardless of outcome. await fetch(providerConfig.revokeUrl, { method: "DELETE", headers: { Authorization: `Bearer ${creds.access_token}` }, }).catch(() => { // Swallow — local unlink proceeds. }); }, };