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:
parent
14784d47c2
commit
895b3004be
2 changed files with 179 additions and 0 deletions
|
|
@ -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));
|
||||
|
|
|
|||
177
server/src/routes/google-oauth.ts
Normal file
177
server/src/routes/google-oauth.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue