Skip to content

Commit b7d34a2

Browse files
committed
Add Copilot token broker Vercel function
1 parent e6300e6 commit b7d34a2

File tree

3 files changed

+1757
-126
lines changed

3 files changed

+1757
-126
lines changed

api/copilot-token.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type { VercelRequest, VercelResponse } from '@vercel/node';
2+
3+
function parseTokenEndpoint(tokenEndpoint: string) {
4+
const baseIdx = tokenEndpoint.indexOf('/powervirtualagents');
5+
if (baseIdx === -1) throw new Error("Token endpoint must include '/powervirtualagents'.");
6+
7+
const base = tokenEndpoint.slice(0, baseIdx);
8+
9+
const match = tokenEndpoint.match(/api-version=([^&]+)/);
10+
if (!match) throw new Error("Token endpoint must include 'api-version='.");
11+
const apiVersion = match[1];
12+
13+
return { base, apiVersion };
14+
}
15+
16+
function getAllowedOrigins() {
17+
// Set in Vercel as: https://www.lockitlending.com,https://lockitlending.com,http://localhost:5173
18+
const raw = process.env.ALLOWED_ORIGINS ?? '';
19+
const list = raw
20+
.split(',')
21+
.map(s => s.trim())
22+
.filter(Boolean);
23+
24+
// Fallback (still recommend setting ALLOWED_ORIGINS in Vercel)
25+
if (list.length === 0) {
26+
return new Set([
27+
'http://localhost:5173',
28+
'http://localhost:3000',
29+
'https://lockitlending.com',
30+
'https://www.lockitlending.com',
31+
]);
32+
}
33+
34+
return new Set(list);
35+
}
36+
37+
function setCors(res: VercelResponse, origin: string) {
38+
res.setHeader('Access-Control-Allow-Origin', origin);
39+
res.setHeader('Vary', 'Origin');
40+
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
41+
res.setHeader('Access-Control-Allow-Headers', 'content-type');
42+
res.setHeader('Cache-Control', 'no-store');
43+
}
44+
45+
export default async function handler(req: VercelRequest, res: VercelResponse) {
46+
const allowed = getAllowedOrigins();
47+
const origin = (req.headers.origin as string | undefined) ?? '';
48+
49+
// IMPORTANT: actually BLOCK disallowed origins (CORS headers alone don’t stop abuse)
50+
if (!origin || !allowed.has(origin)) {
51+
return res.status(403).json({ error: 'Forbidden origin' });
52+
}
53+
54+
// If allowed, set CORS headers
55+
setCors(res, origin);
56+
57+
if (req.method === 'OPTIONS') return res.status(204).end();
58+
if (req.method !== 'POST') return res.status(405).json({ error: 'POST only' });
59+
60+
try {
61+
const secret = process.env.COPILOT_WEB_SECRET;
62+
const tokenEndpoint = process.env.COPILOT_TOKEN_ENDPOINT;
63+
64+
if (!secret || !tokenEndpoint) {
65+
return res.status(500).json({
66+
error: 'Missing env vars: COPILOT_WEB_SECRET and/or COPILOT_TOKEN_ENDPOINT',
67+
});
68+
}
69+
70+
const { base, apiVersion } = parseTokenEndpoint(tokenEndpoint);
71+
const url = `${base}/copilotstudio/directline/token?api-version=${apiVersion}`;
72+
73+
// Timeout to avoid hanging functions
74+
const controller = new AbortController();
75+
const timeout = setTimeout(() => controller.abort(), 8000);
76+
77+
const resp = await fetch(url, {
78+
method: 'POST',
79+
headers: {
80+
Authorization: `Bearer ${secret}`,
81+
'Content-Type': 'application/json',
82+
},
83+
body: JSON.stringify({}),
84+
signal: controller.signal,
85+
}).finally(() => clearTimeout(timeout));
86+
87+
const text = await resp.text();
88+
89+
if (!resp.ok) {
90+
// Don’t leak upstream details in production
91+
const isProd = process.env.VERCEL_ENV === 'production';
92+
return res.status(502).json({
93+
error: 'Token exchange failed',
94+
status: resp.status,
95+
...(isProd ? {} : { details: text.slice(0, 500) }),
96+
});
97+
}
98+
99+
res.setHeader('Content-Type', 'application/json');
100+
return res.status(200).send(text);
101+
} catch (e: any) {
102+
const msg = e?.name === 'AbortError' ? 'Upstream timeout' : (e?.message ?? 'Server error');
103+
return res.status(502).json({ error: msg });
104+
}
105+
}

0 commit comments

Comments
 (0)