Infrastructure build to support third-party app integrations
This commit is contained in:
182
backend/services/oauth.service.ts
Normal file
182
backend/services/oauth.service.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user