th3w1zard1's picture
Deploy GPTR trask-http from community-bots local CI
3a25f97 verified
export type SocialAuthProvider = "google" | "discord" | "github";
export const SOCIAL_AUTH_PROVIDERS = ["google", "discord", "github"] as const satisfies readonly SocialAuthProvider[];
export interface SocialAuthProviderEnvKeys {
clientId: string;
clientSecret: string;
callbackUrl: string;
startUrl: string;
}
export type SocialAuthProviderEnvMap = Record<SocialAuthProvider, SocialAuthProviderEnvKeys>;
export interface SocialAuthProviderConfig {
provider: SocialAuthProvider;
clientId: string;
clientSecret: string;
callbackUrl: string;
startUrl: string;
enabled: boolean;
}
export interface SocialAuthProviderStatus {
provider: SocialAuthProvider;
enabled: boolean;
}
export interface SocialAuthCodeExchangeConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
}
export interface SocialAuthProfile {
providerUserId: string;
username: string;
displayName: string;
email: string | null;
}
export interface FetchDiscordSocialAuthProfileOptions {
discordApiBase?: string;
resolveDisplayName?: (profile: { username: string; global_name: string | null }) => string;
codeVerifier?: string;
}
export interface FetchGithubSocialAuthProfileOptions {
userAgent?: string;
}
export interface BuildSocialAuthAuthorizeUrlOptions {
discordApiBase?: string;
googlePrompt?: string;
discordPrompt?: string;
}
export interface SocialAuthAuthorizeUrlInput {
clientId: string;
redirectUri: string;
state: string;
startUrl?: string;
}
export type EnvLookup = (key: string) => string | undefined;
export interface ResolveSocialAuthProviderConfigOptions {
envMap?: SocialAuthProviderEnvMap;
fallbackEnvKeys?: Partial<Record<SocialAuthProvider, Partial<SocialAuthProviderEnvKeys>>>;
}
export const DEFAULT_SOCIAL_AUTH_PROVIDER_ENV_MAP = {
google: {
clientId: "GOOGLE_CLIENT_ID",
clientSecret: "GOOGLE_CLIENT_SECRET",
callbackUrl: "GOOGLE_REDIRECT_URI",
startUrl: "GOOGLE_URL",
},
discord: {
clientId: "DISCORD_CLIENT_ID",
clientSecret: "DISCORD_CLIENT_SECRET",
callbackUrl: "DISCORD_REDIRECT_URI",
startUrl: "DISCORD_URL",
},
github: {
clientId: "GITHUB_CLIENT_ID",
clientSecret: "GITHUB_CLIENT_SECRET",
callbackUrl: "GITHUB_REDIRECT_URI",
startUrl: "GITHUB_URL",
},
} as const satisfies SocialAuthProviderEnvMap;
export const PAZAAK_SOCIAL_AUTH_PROVIDER_ENV_MAP = {
google: {
clientId: "PAZAAK_OAUTH_GOOGLE_CLIENT_ID",
clientSecret: "PAZAAK_OAUTH_GOOGLE_CLIENT_SECRET",
callbackUrl: "PAZAAK_OAUTH_GOOGLE_CALLBACK_URL",
startUrl: "PAZAAK_OAUTH_GOOGLE_URL",
},
discord: {
clientId: "PAZAAK_OAUTH_DISCORD_CLIENT_ID",
clientSecret: "PAZAAK_OAUTH_DISCORD_CLIENT_SECRET",
callbackUrl: "PAZAAK_OAUTH_DISCORD_CALLBACK_URL",
startUrl: "PAZAAK_OAUTH_DISCORD_URL",
},
github: {
clientId: "PAZAAK_OAUTH_GITHUB_CLIENT_ID",
clientSecret: "PAZAAK_OAUTH_GITHUB_CLIENT_SECRET",
callbackUrl: "PAZAAK_OAUTH_GITHUB_CALLBACK_URL",
startUrl: "PAZAAK_OAUTH_GITHUB_URL",
},
} as const satisfies SocialAuthProviderEnvMap;
const readTrimmed = (lookup: EnvLookup, key: string | undefined): string => {
if (!key) {
return "";
}
return lookup(key)?.trim() ?? "";
};
export const createObjectEnvLookup = (source: object): EnvLookup => {
return (key) => {
const value = (source as Record<string, string | number | boolean | null | object | undefined>)[key];
return typeof value === "string" ? value : undefined;
};
};
export const resolveSocialAuthProviderConfig = (
provider: SocialAuthProvider,
lookup: EnvLookup,
options: ResolveSocialAuthProviderConfigOptions = {},
): SocialAuthProviderConfig => {
const envMap = options.envMap ?? DEFAULT_SOCIAL_AUTH_PROVIDER_ENV_MAP;
const envKeys = envMap[provider];
const fallbackKeys = options.fallbackEnvKeys?.[provider];
const clientId = readTrimmed(lookup, envKeys.clientId) || readTrimmed(lookup, fallbackKeys?.clientId);
const clientSecret = readTrimmed(lookup, envKeys.clientSecret) || readTrimmed(lookup, fallbackKeys?.clientSecret);
const callbackUrl = readTrimmed(lookup, envKeys.callbackUrl) || readTrimmed(lookup, fallbackKeys?.callbackUrl);
const startUrl = readTrimmed(lookup, envKeys.startUrl) || readTrimmed(lookup, fallbackKeys?.startUrl);
return {
provider,
clientId,
clientSecret,
callbackUrl,
startUrl,
enabled: Boolean(clientId && clientSecret),
};
};
export const listSocialAuthProviderConfigs = (
lookup: EnvLookup,
options: ResolveSocialAuthProviderConfigOptions = {},
): SocialAuthProviderConfig[] => {
return SOCIAL_AUTH_PROVIDERS.map((provider) => resolveSocialAuthProviderConfig(provider, lookup, options));
};
export const listSocialAuthProviders = (
lookup: EnvLookup,
options: ResolveSocialAuthProviderConfigOptions = {},
): SocialAuthProviderStatus[] => {
return listSocialAuthProviderConfigs(lookup, options).map(({ provider, enabled }) => ({ provider, enabled }));
};
const readErrorMessage = async (response: Response, fallback: string): Promise<string> => {
const body = await response.text();
return body.trim() || fallback;
};
const toBasicAuthHeader = (clientId: string, clientSecret: string): string => {
const payload = `${clientId}:${clientSecret}`;
const maybeBuffer = (globalThis as { Buffer?: { from(input: string): { toString(encoding: string): string } } }).Buffer;
if (maybeBuffer) {
return `Basic ${maybeBuffer.from(payload).toString("base64")}`;
}
return `Basic ${btoa(payload)}`;
};
const expectAccessToken = (provider: string, value: { access_token?: string }, status = 401): string => {
if (!value.access_token) {
throw Object.assign(new Error(`${provider} token response did not include an access token.`), { status });
}
return value.access_token;
};
export const fetchGoogleSocialAuthProfile = async (
code: string,
config: SocialAuthCodeExchangeConfig,
): Promise<SocialAuthProfile> => {
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: config.redirectUri,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
const message = await readErrorMessage(tokenRes, "Google token exchange failed.");
throw Object.assign(new Error(`Google token exchange failed: ${message}`), { status: 401 });
}
const accessToken = expectAccessToken("Google", await tokenRes.json() as { access_token?: string });
const profileRes = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!profileRes.ok) {
const message = await readErrorMessage(profileRes, "Google profile fetch failed.");
throw Object.assign(new Error(`Google profile fetch failed: ${message}`), { status: 401 });
}
const profile = await profileRes.json() as {
sub?: string;
email?: string;
name?: string;
given_name?: string;
};
if (!profile.sub) {
throw Object.assign(new Error("Google profile response did not include a user id."), { status: 401 });
}
return {
providerUserId: profile.sub,
username: profile.email?.split("@")[0] || profile.name || "google-user",
displayName: profile.name || profile.given_name || profile.email || "Google User",
email: profile.email?.trim() || null,
};
};
export const fetchDiscordSocialAuthProfile = async (
code: string,
config: SocialAuthCodeExchangeConfig,
options: FetchDiscordSocialAuthProfileOptions = {},
): Promise<SocialAuthProfile> => {
const discordApiBase = options.discordApiBase ?? "https://discord.com/api/v10";
const resolveDisplayName = options.resolveDisplayName ?? ((profile: { username: string; global_name: string | null }) => profile.global_name?.trim() || profile.username);
const body = new URLSearchParams({
code,
client_id: config.clientId,
redirect_uri: config.redirectUri,
grant_type: "authorization_code",
});
if (config.clientSecret?.trim()) {
body.set("client_secret", config.clientSecret.trim());
}
if (options.codeVerifier?.trim()) {
body.set("code_verifier", options.codeVerifier.trim());
}
const headers: Record<string, string> = { "Content-Type": "application/x-www-form-urlencoded" };
if (config.clientSecret?.trim()) {
headers.Authorization = toBasicAuthHeader(config.clientId, config.clientSecret.trim());
}
const tokenRes = await fetch(`${discordApiBase}/oauth2/token`, {
method: "POST",
headers,
body,
});
if (!tokenRes.ok) {
const message = await readErrorMessage(tokenRes, "Discord token exchange failed.");
throw Object.assign(new Error(`Discord token exchange failed: ${message}`), { status: 401 });
}
const accessToken = expectAccessToken("Discord", await tokenRes.json() as { access_token?: string });
const profileRes = await fetch(`${discordApiBase}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!profileRes.ok) {
const message = await readErrorMessage(profileRes, "Discord profile fetch failed.");
throw Object.assign(new Error(`Discord profile fetch failed: ${message}`), { status: 401 });
}
const profile = await profileRes.json() as {
id?: string;
username?: string;
global_name?: string | null;
email?: string | null;
};
if (!profile.id || !profile.username) {
throw Object.assign(new Error("Discord profile response did not include a user id."), { status: 401 });
}
return {
providerUserId: profile.id,
username: profile.username,
displayName: resolveDisplayName({ username: profile.username, global_name: profile.global_name ?? null }),
email: profile.email?.trim() || null,
};
};
export const fetchGithubSocialAuthProfile = async (
code: string,
config: SocialAuthCodeExchangeConfig,
options: FetchGithubSocialAuthProfileOptions = {},
): Promise<SocialAuthProfile> => {
const userAgent = options.userAgent ?? "openkotor-platform";
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
code,
client_id: config.clientId,
client_secret: config.clientSecret,
redirect_uri: config.redirectUri,
}),
});
if (!tokenRes.ok) {
const message = await readErrorMessage(tokenRes, "GitHub token exchange failed.");
throw Object.assign(new Error(`GitHub token exchange failed: ${message}`), { status: 401 });
}
const accessToken = expectAccessToken("GitHub", await tokenRes.json() as { access_token?: string });
const profileRes = await fetch("https://api.github.com/user", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": userAgent,
},
});
if (!profileRes.ok) {
const message = await readErrorMessage(profileRes, "GitHub profile fetch failed.");
throw Object.assign(new Error(`GitHub profile fetch failed: ${message}`), { status: 401 });
}
const profile = await profileRes.json() as {
id?: number;
login?: string;
name?: string | null;
email?: string | null;
};
if (profile.id === undefined || !profile.login) {
throw Object.assign(new Error("GitHub profile response did not include a user id."), { status: 401 });
}
let email = profile.email?.trim() || null;
if (!email) {
const emailsRes = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": userAgent,
},
});
if (emailsRes.ok) {
const emails = await emailsRes.json() as Array<{ email: string; primary?: boolean; verified?: boolean }>;
email = emails.find((entry) => entry.primary && entry.verified)?.email
?? emails.find((entry) => entry.verified)?.email
?? emails[0]?.email
?? null;
}
}
return {
providerUserId: String(profile.id),
username: profile.login,
displayName: profile.name || profile.login,
email,
};
};
export const buildSocialAuthAuthorizeUrl = (
provider: SocialAuthProvider,
input: SocialAuthAuthorizeUrlInput,
options: BuildSocialAuthAuthorizeUrlOptions = {},
): string => {
if (input.startUrl) {
return input.startUrl
.replaceAll("{state}", encodeURIComponent(input.state))
.replaceAll("{callback}", encodeURIComponent(input.redirectUri))
.replaceAll("{clientId}", encodeURIComponent(input.clientId));
}
if (provider === "google") {
const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid profile email");
url.searchParams.set("access_type", "online");
url.searchParams.set("state", input.state);
if (options.googlePrompt) {
url.searchParams.set("prompt", options.googlePrompt);
}
return url.toString();
}
if (provider === "discord") {
const url = new URL(`${options.discordApiBase ?? "https://discord.com/api/v10"}/oauth2/authorize`);
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "identify email");
url.searchParams.set("state", input.state);
if (options.discordPrompt) {
url.searchParams.set("prompt", options.discordPrompt);
}
return url.toString();
}
const url = new URL("https://github.com/login/oauth/authorize");
url.searchParams.set("client_id", input.clientId);
url.searchParams.set("redirect_uri", input.redirectUri);
url.searchParams.set("scope", "read:user user:email");
url.searchParams.set("state", input.state);
return url.toString();
};