import { requestJson } from '../lib/httpClient.js'; import { getProvider } from '../config/providers.js'; import { getAccessToken, getRefreshToken, findByProviderWorkspace, updateTokens, } from '../db/integrations.dao.js'; import type { IntegrationRow, ProviderSlug } from '../types/integration.js'; /** Refresh this far ahead of expiry so an in-flight call cannot straddle it. */ const REFRESH_MARGIN_MS = 60_000; export type TokenSet = { accessToken: string; refreshToken?: string; expiresAt?: Date; scopes: string[]; }; type TokenResponse = { access_token: string; refresh_token?: string; expires_in?: number; scope?: string; }; export class OAuthError extends Error { constructor(message: string) { super(message); this.name = 'OAuthError'; } } const isTokenResponse = (value: unknown): value is TokenResponse => { if (typeof value !== 'object' || value === null) return false; const body = value as Record; if (typeof body.access_token !== 'string' || body.access_token.length === 0) return false; if (body.refresh_token !== undefined && typeof body.refresh_token !== 'string') return false; if (body.expires_in !== undefined && typeof body.expires_in !== 'number') return false; if (body.scope !== undefined && typeof body.scope !== 'string') return false; return true; }; const credentials = (provider: ProviderSlug): { id: string; secret: string } => { const prefix = provider.toUpperCase(); const id = process.env[`${prefix}_CLIENT_ID`]; const secret = process.env[`${prefix}_CLIENT_SECRET`]; if (!id || !secret) { throw new OAuthError( `Missing ${prefix}_CLIENT_ID or ${prefix}_CLIENT_SECRET` ); } return { id, secret }; }; const oauthConfig = (provider: ProviderSlug) => { const config = getProvider(provider).oauth; if (!config) { throw new OAuthError(`Provider "${provider}" does not support OAuth`); } return config; }; const toTokenSet = (body: TokenResponse): TokenSet => ({ accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: body.expires_in ? new Date(Date.now() + body.expires_in * 1000) : undefined, scopes: body.scope ? body.scope.split(/[\s,]+/).filter(Boolean) : [], }); export const buildAuthorizeUrl = ( provider: ProviderSlug, input: { redirectUri: string; state: string } ): string => { const config = oauthConfig(provider); const url = new URL(config.authorizeUrl); url.searchParams.set('client_id', credentials(provider).id); url.searchParams.set('redirect_uri', input.redirectUri); url.searchParams.set('response_type', 'code'); url.searchParams.set('state', input.state); url.searchParams.set('scope', config.scopes.join(' ')); return url.toString(); }; const postForm = async ( url: string, form: Record ): Promise => { const body = await requestJson(url, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json', }, body: new URLSearchParams(form).toString(), }); if (!isTokenResponse(body)) { throw new OAuthError('Token endpoint returned an unexpected payload'); } return toTokenSet(body); }; export const exchangeCode = async ( provider: ProviderSlug, input: { code: string; redirectUri: string } ): Promise => { const { id, secret } = credentials(provider); return postForm(oauthConfig(provider).tokenUrl, { grant_type: 'authorization_code', code: input.code, redirect_uri: input.redirectUri, client_id: id, client_secret: secret, }); }; export const refreshAccessToken = async ( provider: ProviderSlug, refreshToken: string ): Promise => { const { id, secret } = credentials(provider); return postForm(oauthConfig(provider).tokenUrl, { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: id, client_secret: secret, }); }; const needsRefresh = (integration: IntegrationRow): boolean => { if (!integration.tokenExpiresAt) return false; return integration.tokenExpiresAt.getTime() - Date.now() <= REFRESH_MARGIN_MS; }; /** * Resolves a usable access token, refreshing first when the stored one is at * or near expiry. Throws rather than returning null so a caller cannot make an * unauthenticated request by forgetting a null check. */ export const getValidAccessToken = async ( provider: ProviderSlug, externalWorkspaceId: string ): Promise => { const integration = await findByProviderWorkspace(provider, externalWorkspaceId); if (!integration) { throw new OAuthError(`No ${provider} integration for workspace ${externalWorkspaceId}`); } if (needsRefresh(integration)) { const refreshToken = await getRefreshToken(integration.id); if (!refreshToken) { throw new OAuthError(`${provider} token expired and no refresh token is stored`); } const tokens = await refreshAccessToken(provider, refreshToken); await updateTokens({ id: integration.id, accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt, }); return tokens.accessToken; } const accessToken = await getAccessToken(integration.id); if (!accessToken) { throw new OAuthError(`No access token stored for ${provider}`); } return accessToken; };