From 720455132a3769cf18377fa99bc585308ceb56ef Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Fri, 3 Apr 2026 00:33:46 +0000 Subject: [PATCH] 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 --- server/src/services/google-oauth.ts | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 server/src/services/google-oauth.ts diff --git a/server/src/services/google-oauth.ts b/server/src/services/google-oauth.ts new file mode 100644 index 00000000..45b7743c --- /dev/null +++ b/server/src/services/google-oauth.ts @@ -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 { + 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 { + 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, + }; +}