feat(31-02): add googleOAuthService with PKCE generation and token management

- 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
This commit is contained in:
Nexus Dev 2026-04-03 00:33:46 +00:00
parent 15f0b1c97a
commit 720455132a

View file

@ -0,0 +1,115 @@
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,
};
}