183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
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<string, unknown>;
|
|
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<string, string>
|
|
): Promise<TokenSet> => {
|
|
const body = await requestJson<unknown>(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<TokenSet> => {
|
|
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<TokenSet> => {
|
|
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<string> => {
|
|
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;
|
|
};
|