- generatePkce() using crypto.randomBytes base64url verifier and SHA256 challenge - generateAuthUrl() builds Google OAuth URL with PKCE params for Gemini scopes - exchangeCode() POSTs to Google token endpoint with code_verifier - storeTokens() upserts google_gemini_oauth_token via secretService - resolveTokens() retrieves and parses stored tokens by companyId
115 lines
3.5 KiB
TypeScript
115 lines
3.5 KiB
TypeScript
import crypto from "node:crypto";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { secretService } from "./secrets.js";
|
|
|
|
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
const GEMINI_CLIENT_ID =
|
|
"812546505895-ag9nvbqvf8cpqk3mfem1glig0jtl5i31.apps.googleusercontent.com";
|
|
const GEMINI_SCOPES =
|
|
"openid https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever";
|
|
const GOOGLE_TOKEN_SECRET_NAME = "google_gemini_oauth_token";
|
|
|
|
export interface GoogleTokens {
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
}
|
|
|
|
export function googleOAuthService(db: Db) {
|
|
const svc = secretService(db);
|
|
|
|
function generatePkce(): { verifier: string; challenge: string } {
|
|
const verifier = crypto.randomBytes(32).toString("base64url");
|
|
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
|
|
return { verifier, challenge };
|
|
}
|
|
|
|
function generateAuthUrl(
|
|
redirectUri: string,
|
|
state: string,
|
|
): { url: string; verifier: string } {
|
|
const { verifier, challenge } = generatePkce();
|
|
const params = new URLSearchParams({
|
|
client_id: GEMINI_CLIENT_ID,
|
|
redirect_uri: redirectUri,
|
|
response_type: "code",
|
|
scope: GEMINI_SCOPES,
|
|
code_challenge: challenge,
|
|
code_challenge_method: "S256",
|
|
state,
|
|
access_type: "offline",
|
|
prompt: "consent",
|
|
});
|
|
const url = `${GOOGLE_AUTH_URL}?${params.toString()}`;
|
|
return { url, verifier };
|
|
}
|
|
|
|
async function exchangeCode(
|
|
code: string,
|
|
redirectUri: string,
|
|
verifier: string,
|
|
): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> {
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: GEMINI_CLIENT_ID,
|
|
code,
|
|
redirect_uri: redirectUri,
|
|
code_verifier: verifier,
|
|
});
|
|
|
|
const response = await fetch(GOOGLE_TOKEN_URL, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Google token exchange failed: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
access_token: string;
|
|
refresh_token?: string;
|
|
expires_in?: number;
|
|
};
|
|
|
|
return {
|
|
accessToken: data.access_token,
|
|
refreshToken: data.refresh_token,
|
|
expiresIn: data.expires_in,
|
|
};
|
|
}
|
|
|
|
async function storeTokens(companyId: string, tokens: GoogleTokens): Promise<void> {
|
|
const value = JSON.stringify(tokens);
|
|
const existing = await svc.getByName(companyId, GOOGLE_TOKEN_SECRET_NAME);
|
|
if (existing) {
|
|
await svc.rotate(existing.id, { value });
|
|
} else {
|
|
await svc.create(companyId, {
|
|
name: GOOGLE_TOKEN_SECRET_NAME,
|
|
provider: "local_encrypted",
|
|
value,
|
|
description: "Google OAuth tokens for Gemini API access",
|
|
});
|
|
}
|
|
}
|
|
|
|
async function resolveTokens(companyId: string): Promise<GoogleTokens> {
|
|
const secret = await svc.getByName(companyId, GOOGLE_TOKEN_SECRET_NAME);
|
|
if (!secret) {
|
|
throw new Error("Google OAuth tokens not found for this company");
|
|
}
|
|
const value = await svc.resolveSecretValue(companyId, secret.id, "latest");
|
|
return JSON.parse(value) as GoogleTokens;
|
|
}
|
|
|
|
return {
|
|
generatePkce,
|
|
generateAuthUrl,
|
|
exchangeCode,
|
|
storeTokens,
|
|
resolveTokens,
|
|
};
|
|
}
|