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:
parent
15f0b1c97a
commit
720455132a
1 changed files with 115 additions and 0 deletions
115
server/src/services/google-oauth.ts
Normal file
115
server/src/services/google-oauth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue