diff --git a/server/src/app.ts b/server/src/app.ts index aeaf189e..f3169b28 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -21,6 +21,7 @@ import { goalRoutes } from "./routes/goals.js"; import { approvalRoutes } from "./routes/approvals.js"; import { secretRoutes } from "./routes/secrets.js"; import { costRoutes } from "./routes/costs.js"; +import { googleOAuthRoutes } from "./routes/google-oauth.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js"; @@ -167,6 +168,7 @@ export async function createApp( api.use(approvalRoutes(db)); api.use(secretRoutes(db)); api.use(costRoutes(db)); + api.use(googleOAuthRoutes(db)); api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); diff --git a/server/src/routes/google-oauth.ts b/server/src/routes/google-oauth.ts new file mode 100644 index 00000000..08ad1840 --- /dev/null +++ b/server/src/routes/google-oauth.ts @@ -0,0 +1,177 @@ +import crypto from "node:crypto"; +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { googleOAuthService } from "../services/google-oauth.js"; +import { assertBoard, assertCompanyAccess } from "./authz.js"; +import { secretService } from "../services/secrets.js"; + +const TEN_MINUTES_MS = 10 * 60 * 1000; + +// In-memory maps for the OAuth PKCE flow +// pendingPkce: keyed by stateId — stores PKCE verifier (NO companyId at authorize time) +const pendingPkce = new Map(); +// pendingTokens: keyed by stateId — stores exchanged tokens after callback completes +const pendingTokens = new Map< + string, + { accessToken: string; refreshToken?: string; createdAt: number } +>(); + +function cleanupExpired() { + const now = Date.now(); + for (const [key, value] of pendingPkce) { + if (now - value.createdAt > TEN_MINUTES_MS) { + pendingPkce.delete(key); + } + } + for (const [key, value] of pendingTokens) { + if (now - value.createdAt > TEN_MINUTES_MS) { + pendingTokens.delete(key); + } + } +} + +function buildRedirectUri(req: { protocol: string; get(name: string): string | undefined }): string { + return `${req.protocol}://${req.get("host")}/api/oauth/google/callback`; +} + +export function googleOAuthRoutes(db: Db): Router { + const router = Router(); + const oauthSvc = googleOAuthService(db); + + // POST /oauth/google/authorize + // Board auth required. Generates PKCE auth URL. + // NO companyId accepted — company does not exist yet during onboarding step 3. + router.post("/oauth/google/authorize", (req, res) => { + cleanupExpired(); + assertBoard(req); + + const state = crypto.randomUUID(); + const redirectUri = buildRedirectUri(req); + const { url, verifier } = oauthSvc.generateAuthUrl(redirectUri, state); + + pendingPkce.set(state, { verifier, createdAt: Date.now() }); + + res.json({ url, stateId: state }); + }); + + // GET /oauth/google/callback + // No auth required — Google redirects here after OAuth consent. + // Exchanges code for tokens and parks them in pendingTokens keyed by stateId. + router.get("/oauth/google/callback", async (req, res) => { + cleanupExpired(); + + const code = req.query["code"] as string | undefined; + const state = req.query["state"] as string | undefined; + + if (!code || !state) { + res.status(400).send("Missing code or state"); + return; + } + + const pkceEntry = pendingPkce.get(state); + if (!pkceEntry) { + res.status(400).send("Invalid or expired OAuth state"); + return; + } + + pendingPkce.delete(state); + + try { + const redirectUri = buildRedirectUri(req); + const { accessToken, refreshToken } = await oauthSvc.exchangeCode( + code, + redirectUri, + pkceEntry.verifier, + ); + + pendingTokens.set(state, { accessToken, refreshToken, createdAt: Date.now() }); + + res.redirect(`/?google_oauth=success&state=${encodeURIComponent(state)}`); + } catch (_err) { + res.redirect("/?google_oauth=error"); + } + }); + + // POST /oauth/google/claim + // Board auth required. Links pending tokens to a real companyId. + // stateId links back to the tokens parked by the callback. + router.post("/oauth/google/claim", async (req, res) => { + cleanupExpired(); + assertBoard(req); + + const { stateId, companyId } = req.body as { stateId?: string; companyId?: string }; + + if (!stateId || typeof stateId !== "string" || stateId.trim() === "") { + res.status(400).json({ error: "stateId is required" }); + return; + } + if (!companyId || typeof companyId !== "string" || companyId.trim() === "") { + res.status(400).json({ error: "companyId is required" }); + return; + } + + assertCompanyAccess(req, companyId); + + const tokenEntry = pendingTokens.get(stateId); + if (!tokenEntry) { + res.status(404).json({ error: "OAuth session expired or not found" }); + return; + } + + await oauthSvc.storeTokens(companyId, { + accessToken: tokenEntry.accessToken, + refreshToken: tokenEntry.refreshToken, + }); + + pendingTokens.delete(stateId); + + res.json({ ok: true }); + }); + + // POST /api-keys/store + // Board auth required. Stores arbitrary provider API keys. + router.post("/api-keys/store", async (req, res) => { + cleanupExpired(); + assertBoard(req); + + const { companyId, provider, apiKey } = req.body as { + companyId?: string; + provider?: string; + apiKey?: string; + }; + + if (!companyId || typeof companyId !== "string" || companyId.trim() === "") { + res.status(400).json({ error: "companyId is required" }); + return; + } + if (!provider || typeof provider !== "string" || provider.trim() === "") { + res.status(400).json({ error: "provider is required" }); + return; + } + if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") { + res.status(400).json({ error: "apiKey is required" }); + return; + } + + assertCompanyAccess(req, companyId); + + const svc = secretService(db); + const secretName = `${provider}_api_key`; + + const existing = await svc.getByName(companyId, secretName); + if (existing) { + await svc.rotate(existing.id, { value: apiKey }); + } else { + await svc.create(companyId, { + name: secretName, + provider: "local_encrypted", + value: apiKey, + description: `API key for ${provider}`, + }); + } + + res.json({ ok: true }); + }); + + return router; +}