feat(31-02): add googleOAuthRoutes with pendingTokens pattern and mount in app.ts

- POST /oauth/google/authorize: returns {url, stateId}, stores PKCE verifier only (no companyId)
- GET /oauth/google/callback: exchanges code, parks tokens in pendingTokens by stateId
- POST /oauth/google/claim: moves tokens from pendingTokens to secretService with real companyId
- POST /api-keys/store: upserts provider API keys (openai/anthropic/groq) via secretService
- Cleanup of entries older than 10 minutes on each request
- Mounted in app.ts via api.use(googleOAuthRoutes(db))
This commit is contained in:
Nexus Dev 2026-04-03 00:34:39 +00:00
parent 14784d47c2
commit 895b3004be
2 changed files with 179 additions and 0 deletions

View file

@ -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));

View file

@ -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<string, { verifier: string; createdAt: number }>();
// 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;
}