Files
kongruity/backend/services/oauth.service.ts

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;
};