Initial commit
This commit is contained in:
619
src/lib/oauth/providers.js
Normal file
619
src/lib/oauth/providers.js
Normal file
@@ -0,0 +1,619 @@
|
||||
/**
|
||||
* OAuth Provider Configurations and Handlers
|
||||
* Centralized DRY approach for all OAuth providers
|
||||
*/
|
||||
|
||||
import { generatePKCE, generateState } from "./utils/pkce";
|
||||
import {
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CONFIG,
|
||||
QWEN_CONFIG,
|
||||
IFLOW_CONFIG,
|
||||
ANTIGRAVITY_CONFIG,
|
||||
GITHUB_CONFIG,
|
||||
} from "./constants/oauth";
|
||||
|
||||
// Provider configurations
|
||||
const PROVIDERS = {
|
||||
claude: {
|
||||
config: CLAUDE_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
state: state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
|
||||
// Parse code - may contain state after #
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
const parts = authCode.split("#");
|
||||
authCode = parts[0];
|
||||
codeState = parts[1] || "";
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
}),
|
||||
},
|
||||
|
||||
codex: {
|
||||
config: CODEX_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
fixedPort: 1455,
|
||||
callbackPath: "/auth/callback",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
...config.extraParams,
|
||||
state: state,
|
||||
};
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
return `${config.authorizeUrl}?${queryString}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
},
|
||||
|
||||
"gemini-cli": {
|
||||
config: GEMINI_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
// Fetch project ID
|
||||
let projectId = "";
|
||||
try {
|
||||
const projectRes = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (projectRes.ok) {
|
||||
const data = await projectRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch project ID:", e);
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
},
|
||||
|
||||
antigravity: {
|
||||
config: ANTIGRAVITY_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
// Fetch project ID from loadCodeAssist
|
||||
let projectId = "";
|
||||
try {
|
||||
const projectRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
|
||||
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" },
|
||||
}),
|
||||
});
|
||||
if (projectRes.ok) {
|
||||
const data = await projectRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch project ID:", e);
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
},
|
||||
|
||||
iflow: {
|
||||
config: IFLOW_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
loginMethod: config.extraParams.loginMethod,
|
||||
type: config.extraParams.type,
|
||||
redirect: redirectUri,
|
||||
state: state,
|
||||
client_id: config.clientId,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
// Create Basic Auth header
|
||||
const basicAuth = Buffer.from(
|
||||
`${config.clientId}:${config.clientSecret}`
|
||||
).toString("base64");
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(
|
||||
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const result = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
const userInfo = result.success ? result.data : {};
|
||||
return { userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
apiKey: extra?.userInfo?.apiKey,
|
||||
email: extra?.userInfo?.email || extra?.userInfo?.phone,
|
||||
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
|
||||
}),
|
||||
},
|
||||
|
||||
qwen: {
|
||||
config: QWEN_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config, codeChallenge) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: { resourceUrl: tokens.resource_url },
|
||||
}),
|
||||
},
|
||||
|
||||
github: {
|
||||
config: GITHUB_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scopes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
// Handle response properly - if not ok, try to get error as text first
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
// If response is not JSON, get as text
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Get Copilot token using GitHub access token
|
||||
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
|
||||
|
||||
// Get user info from GitHub
|
||||
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const userInfo = userRes.ok ? await userRes.json() : {};
|
||||
|
||||
return { copilotToken, userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
copilotToken: extra?.copilotToken?.token,
|
||||
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
|
||||
githubUserId: extra?.userInfo?.id,
|
||||
githubLogin: extra?.userInfo?.login,
|
||||
githubName: extra?.userInfo?.name,
|
||||
githubEmail: extra?.userInfo?.email,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get provider handler
|
||||
*/
|
||||
export function getProvider(name) {
|
||||
const provider = PROVIDERS[name];
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider: ${name}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider names
|
||||
*/
|
||||
export function getProviderNames() {
|
||||
return Object.keys(PROVIDERS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate auth data for a provider
|
||||
*/
|
||||
export function generateAuthData(providerName, redirectUri) {
|
||||
const provider = getProvider(providerName);
|
||||
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
||||
|
||||
let authUrl;
|
||||
if (provider.flowType === "device_code") {
|
||||
// Device code flow doesn't have auth URL upfront
|
||||
authUrl = null;
|
||||
} else if (provider.flowType === "authorization_code_pkce") {
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
|
||||
} else {
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state);
|
||||
}
|
||||
|
||||
return {
|
||||
authUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
codeChallenge,
|
||||
redirectUri,
|
||||
flowType: provider.flowType,
|
||||
fixedPort: provider.fixedPort,
|
||||
callbackPath: provider.callbackPath || "/callback",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange code for tokens
|
||||
*/
|
||||
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) {
|
||||
const provider = getProvider(providerName);
|
||||
|
||||
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state);
|
||||
|
||||
let extra = null;
|
||||
if (provider.postExchange) {
|
||||
extra = await provider.postExchange(tokens);
|
||||
}
|
||||
|
||||
return provider.mapTokens(tokens, extra);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request device code (for device_code flow)
|
||||
*/
|
||||
export async function requestDeviceCode(providerName, codeChallenge) {
|
||||
const provider = getProvider(providerName);
|
||||
if (provider.flowType !== "device_code") {
|
||||
throw new Error(`Provider ${providerName} does not support device code flow`);
|
||||
}
|
||||
return await provider.requestDeviceCode(provider.config, codeChallenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for token (for device_code flow)
|
||||
*/
|
||||
export async function pollForToken(providerName, deviceCode, codeVerifier) {
|
||||
const provider = getProvider(providerName);
|
||||
if (provider.flowType !== "device_code") {
|
||||
throw new Error(`Provider ${providerName} does not support device code flow`);
|
||||
}
|
||||
|
||||
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier);
|
||||
|
||||
if (result.ok) {
|
||||
// For device code flows, success is only when we have an access token
|
||||
if (result.data.access_token) {
|
||||
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
|
||||
let extra = null;
|
||||
if (provider.postExchange) {
|
||||
extra = await provider.postExchange(result.data);
|
||||
}
|
||||
return { success: true, tokens: provider.mapTokens(result.data, extra) };
|
||||
} else {
|
||||
// Check if it's still pending authorization
|
||||
if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') {
|
||||
// This is not a failure, just still waiting
|
||||
return {
|
||||
success: false,
|
||||
error: result.data.error,
|
||||
errorDescription: result.data.error_description || result.data.message,
|
||||
pending: result.data.error === 'authorization_pending'
|
||||
};
|
||||
} else {
|
||||
// Actual error
|
||||
return {
|
||||
success: false,
|
||||
error: result.data.error || 'no_access_token',
|
||||
errorDescription: result.data.error_description || result.data.message || 'No access token received'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user