@becklyn/deployment-protection 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -6
- package/dist/cjs/handler.d.ts.map +1 -1
- package/dist/cjs/handler.js +19 -6
- package/dist/cjs/middleware.d.ts.map +1 -1
- package/dist/cjs/middleware.js +85 -10
- package/dist/cjs/storybook.d.ts +6 -2
- package/dist/cjs/storybook.d.ts.map +1 -1
- package/dist/cjs/storybook.js +6 -2
- package/dist/edge-bundles.json +7 -0
- package/dist/edge.mjs +971 -0
- package/dist/es/handler.d.ts.map +1 -1
- package/dist/es/handler.js +20 -7
- package/dist/es/middleware.d.ts.map +1 -1
- package/dist/es/middleware.js +85 -10
- package/dist/es/storybook.d.ts +6 -2
- package/dist/es/storybook.d.ts.map +1 -1
- package/dist/es/storybook.js +6 -2
- package/dist/storybook.mjs +971 -0
- package/package.json +16 -9
package/dist/edge.mjs
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
// src/constants.ts
|
|
2
|
+
var SESSION_COOKIE_NAME = "__becklyn_dp_session";
|
|
3
|
+
var BYPASS_COOKIE_NAME = "__becklyn_dp_bypass";
|
|
4
|
+
var OAUTH_STATE_COOKIE = "__becklyn_dp_oauth_state";
|
|
5
|
+
var OAUTH_NONCE_COOKIE = "__becklyn_dp_oauth_nonce";
|
|
6
|
+
var OAUTH_VERIFIER_COOKIE = "__becklyn_dp_oauth_verifier";
|
|
7
|
+
var OAUTH_RETURN_COOKIE = "__becklyn_dp_oauth_return";
|
|
8
|
+
var OAUTH_RETURN_ORIGIN_COOKIE = "__becklyn_dp_oauth_return_origin";
|
|
9
|
+
var INTERNAL_PATH_PREFIX = "/_becklyn/deployment-protection";
|
|
10
|
+
var VERCEL_AUTHORIZE_PATH = `${INTERNAL_PATH_PREFIX}/vercel`;
|
|
11
|
+
var VERCEL_CALLBACK_PATH = `${INTERNAL_PATH_PREFIX}/vercel/callback`;
|
|
12
|
+
var BYPASS_HEADER = "x-vercel-protection-bypass";
|
|
13
|
+
var SET_BYPASS_COOKIE_HEADER = "x-vercel-set-bypass-cookie";
|
|
14
|
+
var DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 14;
|
|
15
|
+
|
|
16
|
+
// src/crypto.ts
|
|
17
|
+
var textEncoder = new TextEncoder();
|
|
18
|
+
function bytesToBase64Url(bytes) {
|
|
19
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
20
|
+
let binary = "";
|
|
21
|
+
for (const byte of view) {
|
|
22
|
+
binary += String.fromCharCode(byte);
|
|
23
|
+
}
|
|
24
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
25
|
+
}
|
|
26
|
+
function base64UrlToBytes(value) {
|
|
27
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
28
|
+
const padLength = (4 - padded.length % 4) % 4;
|
|
29
|
+
const base64 = padded + "=".repeat(padLength);
|
|
30
|
+
const binary = atob(base64);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i++) {
|
|
33
|
+
bytes[i] = binary.charCodeAt(i);
|
|
34
|
+
}
|
|
35
|
+
return bytes;
|
|
36
|
+
}
|
|
37
|
+
async function sha256Base64Url(value) {
|
|
38
|
+
const digest = await crypto.subtle.digest("SHA-256", textEncoder.encode(value));
|
|
39
|
+
return bytesToBase64Url(digest);
|
|
40
|
+
}
|
|
41
|
+
async function hmacSign(secret, payload) {
|
|
42
|
+
const key = await crypto.subtle.importKey(
|
|
43
|
+
"raw",
|
|
44
|
+
textEncoder.encode(secret),
|
|
45
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
46
|
+
false,
|
|
47
|
+
["sign"]
|
|
48
|
+
);
|
|
49
|
+
const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload));
|
|
50
|
+
return bytesToBase64Url(signature);
|
|
51
|
+
}
|
|
52
|
+
async function timingSafeEqualString(a, b) {
|
|
53
|
+
const aBytes = textEncoder.encode(a);
|
|
54
|
+
const bBytes = textEncoder.encode(b);
|
|
55
|
+
if (aBytes.length !== bBytes.length) {
|
|
56
|
+
await crypto.subtle.digest("SHA-256", aBytes);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
let mismatch = 0;
|
|
60
|
+
for (let i = 0; i < aBytes.length; i++) {
|
|
61
|
+
mismatch |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
|
|
62
|
+
}
|
|
63
|
+
return mismatch === 0;
|
|
64
|
+
}
|
|
65
|
+
function randomToken(byteLength = 32) {
|
|
66
|
+
const bytes = new Uint8Array(byteLength);
|
|
67
|
+
crypto.getRandomValues(bytes);
|
|
68
|
+
return bytesToBase64Url(bytes);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/bypass.ts
|
|
72
|
+
function readBypassToken(request) {
|
|
73
|
+
const header = request.headers.get(BYPASS_HEADER)?.trim();
|
|
74
|
+
if (header) {
|
|
75
|
+
return header;
|
|
76
|
+
}
|
|
77
|
+
const url = new URL(request.url);
|
|
78
|
+
const query = url.searchParams.get(BYPASS_HEADER)?.trim();
|
|
79
|
+
if (query) {
|
|
80
|
+
return query;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function readBypassCookie(request) {
|
|
85
|
+
const cookieHeader = request.headers.get("cookie");
|
|
86
|
+
if (!cookieHeader) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
for (const part of cookieHeader.split(";")) {
|
|
90
|
+
const [rawName, ...rest] = part.trim().split("=");
|
|
91
|
+
if (rawName === BYPASS_COOKIE_NAME) {
|
|
92
|
+
return decodeURIComponent(rest.join("="));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
async function isValidBypass(provided, secret) {
|
|
98
|
+
if (!provided || !secret) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return timingSafeEqualString(provided, secret);
|
|
102
|
+
}
|
|
103
|
+
function shouldSetBypassCookie(request) {
|
|
104
|
+
const url = new URL(request.url);
|
|
105
|
+
const value = request.headers.get(SET_BYPASS_COOKIE_HEADER) ?? url.searchParams.get(SET_BYPASS_COOKIE_HEADER);
|
|
106
|
+
if (!value) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (value.toLowerCase() === "samesitenone") {
|
|
110
|
+
return "samesitenone";
|
|
111
|
+
}
|
|
112
|
+
return value === "true" || value === "1";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/safe-return-to.ts
|
|
116
|
+
function hasUnsafeCharacters(value) {
|
|
117
|
+
for (let i = 0; i < value.length; i++) {
|
|
118
|
+
const code = value.charCodeAt(i);
|
|
119
|
+
if (code <= 31 || code === 127 || /\s/.test(value[i])) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
function safeReturnTo(value, fallback = "/") {
|
|
126
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
|
|
127
|
+
return fallback;
|
|
128
|
+
}
|
|
129
|
+
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\") || hasUnsafeCharacters(value) || /%5c/i.test(value) || // encoded backslash
|
|
130
|
+
/%00/i.test(value)) {
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
let decoded;
|
|
134
|
+
try {
|
|
135
|
+
decoded = decodeURIComponent(value);
|
|
136
|
+
} catch {
|
|
137
|
+
return fallback;
|
|
138
|
+
}
|
|
139
|
+
if (!decoded.startsWith("/") || decoded.startsWith("//") || decoded.includes("\\") || hasUnsafeCharacters(decoded)) {
|
|
140
|
+
return fallback;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const base = "https://safe.invalid";
|
|
144
|
+
const url = new URL(decoded, base);
|
|
145
|
+
if (url.origin !== base || url.username || url.password) {
|
|
146
|
+
return fallback;
|
|
147
|
+
}
|
|
148
|
+
const sanitized = `${url.pathname}${url.search}${url.hash}`;
|
|
149
|
+
return sanitized.startsWith("/") && !sanitized.startsWith("//") ? sanitized : fallback;
|
|
150
|
+
} catch {
|
|
151
|
+
return fallback;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/handoff.ts
|
|
156
|
+
var PROXY_START_TTL_SECONDS = 5 * 60;
|
|
157
|
+
function decodeJson(encoded) {
|
|
158
|
+
try {
|
|
159
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
160
|
+
return JSON.parse(json);
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function canonicalProxyStartPayload(params) {
|
|
166
|
+
return `v1
|
|
167
|
+
${params.returnOrigin}
|
|
168
|
+
${params.returnPath}
|
|
169
|
+
${params.exp}
|
|
170
|
+
${params.nonce}`;
|
|
171
|
+
}
|
|
172
|
+
async function signProxyStart(secret, params) {
|
|
173
|
+
return hmacSign(secret, canonicalProxyStartPayload(params));
|
|
174
|
+
}
|
|
175
|
+
async function buildProxyStartUrl(options) {
|
|
176
|
+
const returnPath = safeReturnTo(options.returnPath);
|
|
177
|
+
const params = {
|
|
178
|
+
returnOrigin: options.returnOrigin.replace(/\/$/, ""),
|
|
179
|
+
returnPath,
|
|
180
|
+
exp: Math.floor(Date.now() / 1e3) + (options.ttlSeconds ?? PROXY_START_TTL_SECONDS),
|
|
181
|
+
nonce: randomToken(16)
|
|
182
|
+
};
|
|
183
|
+
const sig = await signProxyStart(options.secret, params);
|
|
184
|
+
const url = new URL("/start", ensureTrailingSlashBase(options.authProxyUrl));
|
|
185
|
+
url.searchParams.set("return_origin", params.returnOrigin);
|
|
186
|
+
url.searchParams.set("return_path", params.returnPath);
|
|
187
|
+
url.searchParams.set("exp", String(params.exp));
|
|
188
|
+
url.searchParams.set("nonce", params.nonce);
|
|
189
|
+
url.searchParams.set("sig", sig);
|
|
190
|
+
return url.toString();
|
|
191
|
+
}
|
|
192
|
+
function ensureTrailingSlashBase(value) {
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(value);
|
|
195
|
+
return url.toString().endsWith("/") ? url.toString() : `${url.toString()}/`;
|
|
196
|
+
} catch {
|
|
197
|
+
return value.endsWith("/") ? value : `${value}/`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
async function verifyHandoffToken(secret, token, expectedAudienceOrigin) {
|
|
201
|
+
if (!token) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const [body, signature] = token.split(".");
|
|
205
|
+
if (!body || !signature) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const expected = await hmacSign(secret, body);
|
|
209
|
+
if (!await timingSafeEqualString(signature, expected)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
const payload = decodeJson(body);
|
|
213
|
+
if (!payload || typeof payload.exp !== "number" || typeof payload.aud !== "string" || typeof payload.subject !== "string" || payload.method !== "vercel") {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
if (payload.exp < Math.floor(Date.now() / 1e3)) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const expectedAud = expectedAudienceOrigin.replace(/\/$/, "");
|
|
220
|
+
if (!await timingSafeEqualString(payload.aud, expectedAud)) {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
return payload;
|
|
224
|
+
}
|
|
225
|
+
function parseAllowlist(value) {
|
|
226
|
+
if (!value) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
return value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/config.ts
|
|
233
|
+
var FALSEY = /* @__PURE__ */ new Set(["0", "false", "no", "off"]);
|
|
234
|
+
function read(env, ...keys) {
|
|
235
|
+
for (const key of keys) {
|
|
236
|
+
const value = env[key]?.trim();
|
|
237
|
+
if (value) {
|
|
238
|
+
return value;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
function isEnabled(env) {
|
|
244
|
+
const raw = read(env, "DEPLOYMENT_PROTECTION_ENABLED");
|
|
245
|
+
if (!raw) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
return !FALSEY.has(raw.toLowerCase());
|
|
249
|
+
}
|
|
250
|
+
function resolveConfig(options = {}) {
|
|
251
|
+
const env = {
|
|
252
|
+
...typeof process !== "undefined" ? process.env : {},
|
|
253
|
+
...options.env
|
|
254
|
+
};
|
|
255
|
+
const username = read(env, "DEPLOYMENT_PROTECTION_USERNAME");
|
|
256
|
+
const password = read(env, "DEPLOYMENT_PROTECTION_PASSWORD");
|
|
257
|
+
const explicitSecret = read(env, "DEPLOYMENT_PROTECTION_SECRET");
|
|
258
|
+
const secret = explicitSecret ?? (username && password ? `dp:${username}:${password}` : null);
|
|
259
|
+
const handoffSecret = read(env, "DEPLOYMENT_PROTECTION_HANDOFF_SECRET") ?? explicitSecret ?? secret;
|
|
260
|
+
const authProxyUrl = read(env, "DEPLOYMENT_PROTECTION_AUTH_PROXY_URL")?.replace(/\/$/, "") ?? null;
|
|
261
|
+
return {
|
|
262
|
+
enabled: isEnabled(env),
|
|
263
|
+
username,
|
|
264
|
+
password,
|
|
265
|
+
secret,
|
|
266
|
+
handoffSecret,
|
|
267
|
+
bypassSecret: read(
|
|
268
|
+
env,
|
|
269
|
+
"VERCEL_AUTOMATION_BYPASS_SECRET",
|
|
270
|
+
"DEPLOYMENT_PROTECTION_BYPASS_SECRET"
|
|
271
|
+
),
|
|
272
|
+
authProxyUrl,
|
|
273
|
+
allowedReturnOrigins: parseAllowlist(
|
|
274
|
+
read(
|
|
275
|
+
env,
|
|
276
|
+
"DEPLOYMENT_PROTECTION_ALLOWED_ORIGINS",
|
|
277
|
+
"DEPLOYMENT_PROTECTION_AUTH_PROXY_ALLOWED_ORIGINS"
|
|
278
|
+
)
|
|
279
|
+
),
|
|
280
|
+
vercelClientId: read(
|
|
281
|
+
env,
|
|
282
|
+
"DEPLOYMENT_PROTECTION_VERCEL_CLIENT_ID",
|
|
283
|
+
"NEXT_PUBLIC_VERCEL_APP_CLIENT_ID",
|
|
284
|
+
"VERCEL_APP_CLIENT_ID"
|
|
285
|
+
),
|
|
286
|
+
vercelClientSecret: read(
|
|
287
|
+
env,
|
|
288
|
+
"DEPLOYMENT_PROTECTION_VERCEL_CLIENT_SECRET",
|
|
289
|
+
"VERCEL_APP_CLIENT_SECRET"
|
|
290
|
+
),
|
|
291
|
+
sessionTtlSeconds: options.sessionTtlSeconds ?? 60 * 60 * 24 * 14,
|
|
292
|
+
formTitle: options.form?.title ?? read(env, "DEPLOYMENT_PROTECTION_FORM_TITLE") ?? "Authentication required",
|
|
293
|
+
formDescription: options.form?.description ?? read(env, "DEPLOYMENT_PROTECTION_FORM_DESCRIPTION") ?? "Enter the shared credentials to continue."
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function hasPasswordAuth(config) {
|
|
297
|
+
return Boolean(config.username && config.password && config.secret);
|
|
298
|
+
}
|
|
299
|
+
function hasVercelDirectAuth(config) {
|
|
300
|
+
return Boolean(config.vercelClientId && config.vercelClientSecret && config.secret);
|
|
301
|
+
}
|
|
302
|
+
function hasVercelProxyAuth(config) {
|
|
303
|
+
return Boolean(config.authProxyUrl && config.handoffSecret && config.secret);
|
|
304
|
+
}
|
|
305
|
+
function hasVercelAuth(config) {
|
|
306
|
+
return hasVercelDirectAuth(config) || hasVercelProxyAuth(config);
|
|
307
|
+
}
|
|
308
|
+
function isProtectionActive(config) {
|
|
309
|
+
return config.enabled && (hasPasswordAuth(config) || hasVercelAuth(config) || Boolean(config.bypassSecret));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// src/login-page.ts
|
|
313
|
+
function escapeHtml(value) {
|
|
314
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
315
|
+
}
|
|
316
|
+
function renderLoginPage(config, options = {}) {
|
|
317
|
+
const returnTo = options.returnTo ?? "/";
|
|
318
|
+
const showPassword = options.showPassword ?? true;
|
|
319
|
+
const showVercel = options.showVercel ?? false;
|
|
320
|
+
const error = options.error ? `<p class="error">${escapeHtml(options.error)}</p>` : "";
|
|
321
|
+
const vercelButton = showVercel ? `<a class="secondary" href="${VERCEL_AUTHORIZE_PATH}?return_to=${encodeURIComponent(returnTo)}">Sign in with Vercel</a>` : "";
|
|
322
|
+
const passwordForm = showPassword ? `
|
|
323
|
+
<form method="post" action="${escapeHtml(returnTo)}">
|
|
324
|
+
<input type="hidden" name="__becklyn_dp" value="1" />
|
|
325
|
+
<input type="hidden" name="return_to" value="${escapeHtml(returnTo)}" />
|
|
326
|
+
<label>
|
|
327
|
+
Username
|
|
328
|
+
<input name="username" type="text" autocomplete="username" required autofocus />
|
|
329
|
+
</label>
|
|
330
|
+
<label>
|
|
331
|
+
Password
|
|
332
|
+
<input name="password" type="password" autocomplete="current-password" required />
|
|
333
|
+
</label>
|
|
334
|
+
<button type="submit">Continue</button>
|
|
335
|
+
</form>` : "";
|
|
336
|
+
const divider = showPassword && showVercel ? `<div class="divider"><span>or</span></div>` : "";
|
|
337
|
+
return `<!doctype html>
|
|
338
|
+
<html lang="en">
|
|
339
|
+
<head>
|
|
340
|
+
<meta charset="utf-8" />
|
|
341
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
342
|
+
<title>${escapeHtml(config.formTitle)}</title>
|
|
343
|
+
<style>
|
|
344
|
+
:root {
|
|
345
|
+
color-scheme: light dark;
|
|
346
|
+
--bg: #0b0f19;
|
|
347
|
+
--card: #121826;
|
|
348
|
+
--text: #e8eefc;
|
|
349
|
+
--muted: #9aa8c7;
|
|
350
|
+
--accent: #3b82f6;
|
|
351
|
+
--border: #243044;
|
|
352
|
+
--error: #f87171;
|
|
353
|
+
}
|
|
354
|
+
@media (prefers-color-scheme: light) {
|
|
355
|
+
:root {
|
|
356
|
+
--bg: #f4f7fb;
|
|
357
|
+
--card: #ffffff;
|
|
358
|
+
--text: #0f172a;
|
|
359
|
+
--muted: #64748b;
|
|
360
|
+
--border: #e2e8f0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
* { box-sizing: border-box; }
|
|
364
|
+
body {
|
|
365
|
+
margin: 0;
|
|
366
|
+
min-height: 100vh;
|
|
367
|
+
display: grid;
|
|
368
|
+
place-items: center;
|
|
369
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
|
370
|
+
background:
|
|
371
|
+
radial-gradient(1200px 600px at 10% -10%, rgba(59,130,246,.25), transparent 60%),
|
|
372
|
+
radial-gradient(900px 500px at 100% 0%, rgba(14,165,233,.18), transparent 55%),
|
|
373
|
+
var(--bg);
|
|
374
|
+
color: var(--text);
|
|
375
|
+
padding: 24px;
|
|
376
|
+
}
|
|
377
|
+
.card {
|
|
378
|
+
width: min(100%, 420px);
|
|
379
|
+
background: color-mix(in srgb, var(--card) 92%, transparent);
|
|
380
|
+
border: 1px solid var(--border);
|
|
381
|
+
border-radius: 16px;
|
|
382
|
+
padding: 28px;
|
|
383
|
+
box-shadow: 0 20px 50px rgba(0,0,0,.25);
|
|
384
|
+
backdrop-filter: blur(8px);
|
|
385
|
+
}
|
|
386
|
+
h1 {
|
|
387
|
+
margin: 0 0 8px;
|
|
388
|
+
font-size: 1.35rem;
|
|
389
|
+
letter-spacing: -0.02em;
|
|
390
|
+
}
|
|
391
|
+
p {
|
|
392
|
+
margin: 0 0 20px;
|
|
393
|
+
color: var(--muted);
|
|
394
|
+
line-height: 1.5;
|
|
395
|
+
font-size: .95rem;
|
|
396
|
+
}
|
|
397
|
+
form { display: grid; gap: 14px; }
|
|
398
|
+
label {
|
|
399
|
+
display: grid;
|
|
400
|
+
gap: 6px;
|
|
401
|
+
font-size: .85rem;
|
|
402
|
+
color: var(--muted);
|
|
403
|
+
}
|
|
404
|
+
input {
|
|
405
|
+
width: 100%;
|
|
406
|
+
border: 1px solid var(--border);
|
|
407
|
+
border-radius: 10px;
|
|
408
|
+
padding: 12px 14px;
|
|
409
|
+
font: inherit;
|
|
410
|
+
color: var(--text);
|
|
411
|
+
background: transparent;
|
|
412
|
+
}
|
|
413
|
+
input:focus {
|
|
414
|
+
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
|
415
|
+
border-color: var(--accent);
|
|
416
|
+
}
|
|
417
|
+
button, .secondary {
|
|
418
|
+
appearance: none;
|
|
419
|
+
border: 0;
|
|
420
|
+
border-radius: 10px;
|
|
421
|
+
padding: 12px 14px;
|
|
422
|
+
font: inherit;
|
|
423
|
+
font-weight: 600;
|
|
424
|
+
cursor: pointer;
|
|
425
|
+
text-align: center;
|
|
426
|
+
text-decoration: none;
|
|
427
|
+
}
|
|
428
|
+
button {
|
|
429
|
+
background: var(--accent);
|
|
430
|
+
color: white;
|
|
431
|
+
}
|
|
432
|
+
.secondary {
|
|
433
|
+
display: block;
|
|
434
|
+
background: transparent;
|
|
435
|
+
color: var(--text);
|
|
436
|
+
border: 1px solid var(--border);
|
|
437
|
+
}
|
|
438
|
+
.divider {
|
|
439
|
+
display: grid;
|
|
440
|
+
grid-template-columns: 1fr auto 1fr;
|
|
441
|
+
gap: 12px;
|
|
442
|
+
align-items: center;
|
|
443
|
+
margin: 18px 0;
|
|
444
|
+
color: var(--muted);
|
|
445
|
+
font-size: .8rem;
|
|
446
|
+
text-transform: uppercase;
|
|
447
|
+
letter-spacing: .08em;
|
|
448
|
+
}
|
|
449
|
+
.divider::before, .divider::after {
|
|
450
|
+
content: "";
|
|
451
|
+
height: 1px;
|
|
452
|
+
background: var(--border);
|
|
453
|
+
}
|
|
454
|
+
.error {
|
|
455
|
+
color: var(--error);
|
|
456
|
+
background: color-mix(in srgb, var(--error) 12%, transparent);
|
|
457
|
+
border: 1px solid color-mix(in srgb, var(--error) 35%, transparent);
|
|
458
|
+
border-radius: 10px;
|
|
459
|
+
padding: 10px 12px;
|
|
460
|
+
margin: 0 0 16px;
|
|
461
|
+
}
|
|
462
|
+
</style>
|
|
463
|
+
</head>
|
|
464
|
+
<body>
|
|
465
|
+
<main class="card">
|
|
466
|
+
<h1>${escapeHtml(config.formTitle)}</h1>
|
|
467
|
+
<p>${escapeHtml(config.formDescription)}</p>
|
|
468
|
+
${error}
|
|
469
|
+
${passwordForm}
|
|
470
|
+
${divider}
|
|
471
|
+
${vercelButton}
|
|
472
|
+
</main>
|
|
473
|
+
</body>
|
|
474
|
+
</html>`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// src/password.ts
|
|
478
|
+
async function validatePasswordCredentials(config, username, password) {
|
|
479
|
+
if (!config.username || !config.password) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
const userOk = await timingSafeEqualString(username, config.username);
|
|
483
|
+
const passOk = await timingSafeEqualString(password, config.password);
|
|
484
|
+
return userOk && passOk;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// src/session.ts
|
|
488
|
+
function encodePayload(payload) {
|
|
489
|
+
return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
490
|
+
}
|
|
491
|
+
function decodePayload(encoded) {
|
|
492
|
+
try {
|
|
493
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
494
|
+
const parsed = JSON.parse(json);
|
|
495
|
+
if (typeof parsed.exp !== "number" || typeof parsed.method !== "string" || typeof parsed.subject !== "string") {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
return parsed;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async function createSessionToken(secret, method, subject, ttlSeconds) {
|
|
504
|
+
const payload = {
|
|
505
|
+
exp: Math.floor(Date.now() / 1e3) + ttlSeconds,
|
|
506
|
+
method,
|
|
507
|
+
subject
|
|
508
|
+
};
|
|
509
|
+
const body = encodePayload(payload);
|
|
510
|
+
const signature = await hmacSign(secret, body);
|
|
511
|
+
return `${body}.${signature}`;
|
|
512
|
+
}
|
|
513
|
+
async function verifySessionToken(secret, token) {
|
|
514
|
+
if (!token) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const [body, signature] = token.split(".");
|
|
518
|
+
if (!body || !signature) {
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const expected = await hmacSign(secret, body);
|
|
522
|
+
if (!await timingSafeEqualString(signature, expected)) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
const payload = decodePayload(body);
|
|
526
|
+
if (!payload) {
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
if (payload.exp < Math.floor(Date.now() / 1e3)) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
return payload;
|
|
533
|
+
}
|
|
534
|
+
function sessionCookieOptions(maxAge, secure) {
|
|
535
|
+
return {
|
|
536
|
+
httpOnly: true,
|
|
537
|
+
sameSite: "lax",
|
|
538
|
+
secure,
|
|
539
|
+
path: "/",
|
|
540
|
+
maxAge
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// src/vercel-oauth.ts
|
|
545
|
+
function cookieOptions(secure) {
|
|
546
|
+
return {
|
|
547
|
+
httpOnly: true,
|
|
548
|
+
sameSite: "lax",
|
|
549
|
+
secure,
|
|
550
|
+
path: "/",
|
|
551
|
+
maxAge: 10 * 60
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
function resolveCallbackPath(options) {
|
|
555
|
+
return options?.callbackPath ?? VERCEL_CALLBACK_PATH;
|
|
556
|
+
}
|
|
557
|
+
async function buildVercelAuthorizeRedirect(request, config, returnTo, options) {
|
|
558
|
+
if (!config.vercelClientId) {
|
|
559
|
+
return new Response("Vercel OAuth is not configured", { status: 500 });
|
|
560
|
+
}
|
|
561
|
+
const state = randomToken(32);
|
|
562
|
+
const nonce = randomToken(32);
|
|
563
|
+
const codeVerifier = randomToken(48);
|
|
564
|
+
const codeChallenge = await sha256Base64Url(codeVerifier);
|
|
565
|
+
const origin = new URL(request.url).origin;
|
|
566
|
+
const redirectUri = `${origin}${resolveCallbackPath(options)}`;
|
|
567
|
+
const secure = origin.startsWith("https://");
|
|
568
|
+
const params = new URLSearchParams({
|
|
569
|
+
client_id: config.vercelClientId,
|
|
570
|
+
redirect_uri: redirectUri,
|
|
571
|
+
state,
|
|
572
|
+
nonce,
|
|
573
|
+
code_challenge: codeChallenge,
|
|
574
|
+
code_challenge_method: "S256",
|
|
575
|
+
response_type: "code",
|
|
576
|
+
scope: "openid email profile"
|
|
577
|
+
});
|
|
578
|
+
const response = new Response(null, {
|
|
579
|
+
status: 302,
|
|
580
|
+
headers: {
|
|
581
|
+
Location: `https://vercel.com/oauth/authorize?${params.toString()}`
|
|
582
|
+
}
|
|
583
|
+
});
|
|
584
|
+
const opts = cookieOptions(secure);
|
|
585
|
+
appendSetCookie(response, OAUTH_STATE_COOKIE, state, opts);
|
|
586
|
+
appendSetCookie(response, OAUTH_NONCE_COOKIE, nonce, opts);
|
|
587
|
+
appendSetCookie(response, OAUTH_VERIFIER_COOKIE, codeVerifier, opts);
|
|
588
|
+
appendSetCookie(response, OAUTH_RETURN_COOKIE, returnTo || "/", opts);
|
|
589
|
+
return response;
|
|
590
|
+
}
|
|
591
|
+
async function exchangeVercelCode(request, config, code, codeVerifier, options) {
|
|
592
|
+
if (!config.vercelClientId || !config.vercelClientSecret) {
|
|
593
|
+
throw new Error("Vercel OAuth is not configured");
|
|
594
|
+
}
|
|
595
|
+
const origin = new URL(request.url).origin;
|
|
596
|
+
const body = new URLSearchParams({
|
|
597
|
+
grant_type: "authorization_code",
|
|
598
|
+
client_id: config.vercelClientId,
|
|
599
|
+
client_secret: config.vercelClientSecret,
|
|
600
|
+
code,
|
|
601
|
+
code_verifier: codeVerifier,
|
|
602
|
+
redirect_uri: `${origin}${resolveCallbackPath(options)}`
|
|
603
|
+
});
|
|
604
|
+
const response = await fetch("https://api.vercel.com/login/oauth/token", {
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: {
|
|
607
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
608
|
+
},
|
|
609
|
+
body
|
|
610
|
+
});
|
|
611
|
+
if (!response.ok) {
|
|
612
|
+
const errorText = await response.text();
|
|
613
|
+
throw new Error(`Token exchange failed: ${errorText}`);
|
|
614
|
+
}
|
|
615
|
+
return await response.json();
|
|
616
|
+
}
|
|
617
|
+
async function fetchVercelUserInfo(accessToken) {
|
|
618
|
+
const response = await fetch("https://api.vercel.com/login/oauth/userinfo", {
|
|
619
|
+
headers: {
|
|
620
|
+
Authorization: `Bearer ${accessToken}`
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
if (!response.ok) {
|
|
624
|
+
throw new Error("Failed to load Vercel user info");
|
|
625
|
+
}
|
|
626
|
+
return await response.json();
|
|
627
|
+
}
|
|
628
|
+
function readCookie(request, name) {
|
|
629
|
+
const cookieHeader = request.headers.get("cookie");
|
|
630
|
+
if (!cookieHeader) {
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
for (const part of cookieHeader.split(";")) {
|
|
634
|
+
const [rawName, ...rest] = part.trim().split("=");
|
|
635
|
+
if (rawName === name) {
|
|
636
|
+
return decodeURIComponent(rest.join("="));
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
async function assertOAuthState(request, state) {
|
|
642
|
+
const stored = readCookie(request, OAUTH_STATE_COOKIE);
|
|
643
|
+
if (!state || !stored) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
return timingSafeEqualString(state, stored);
|
|
647
|
+
}
|
|
648
|
+
function decodeIdTokenNonce(idToken) {
|
|
649
|
+
if (!idToken) {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
const parts = idToken.split(".");
|
|
653
|
+
const payload = parts[1];
|
|
654
|
+
if (!payload) {
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const padded = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
659
|
+
const padLength = (4 - padded.length % 4) % 4;
|
|
660
|
+
const json = atob(padded + "=".repeat(padLength));
|
|
661
|
+
const data = JSON.parse(json);
|
|
662
|
+
return data.nonce ?? null;
|
|
663
|
+
} catch {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function clearOAuthCookies(response, secure) {
|
|
668
|
+
const expired = {
|
|
669
|
+
httpOnly: true,
|
|
670
|
+
sameSite: "lax",
|
|
671
|
+
secure,
|
|
672
|
+
path: "/",
|
|
673
|
+
maxAge: 0
|
|
674
|
+
};
|
|
675
|
+
for (const name of [
|
|
676
|
+
OAUTH_STATE_COOKIE,
|
|
677
|
+
OAUTH_NONCE_COOKIE,
|
|
678
|
+
OAUTH_VERIFIER_COOKIE,
|
|
679
|
+
OAUTH_RETURN_COOKIE,
|
|
680
|
+
OAUTH_RETURN_ORIGIN_COOKIE
|
|
681
|
+
]) {
|
|
682
|
+
appendSetCookie(response, name, "", expired);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function appendSetCookie(response, name, value, options) {
|
|
686
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
687
|
+
if (options.maxAge !== void 0) {
|
|
688
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
689
|
+
}
|
|
690
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
691
|
+
if (options.httpOnly) {
|
|
692
|
+
parts.push("HttpOnly");
|
|
693
|
+
}
|
|
694
|
+
if (options.secure) {
|
|
695
|
+
parts.push("Secure");
|
|
696
|
+
}
|
|
697
|
+
if (options.sameSite) {
|
|
698
|
+
parts.push(
|
|
699
|
+
`SameSite=${options.sameSite === "none" ? "None" : options.sameSite[0].toUpperCase()}${options.sameSite.slice(1)}`
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
response.headers.append("Set-Cookie", parts.join("; "));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// src/handler.ts
|
|
706
|
+
function isSecureRequest(request) {
|
|
707
|
+
return new URL(request.url).protocol === "https:";
|
|
708
|
+
}
|
|
709
|
+
function getPathname(request) {
|
|
710
|
+
return new URL(request.url).pathname;
|
|
711
|
+
}
|
|
712
|
+
function htmlResponse(html, status = 401) {
|
|
713
|
+
return new Response(html, {
|
|
714
|
+
status,
|
|
715
|
+
headers: {
|
|
716
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
717
|
+
"Cache-Control": "no-store"
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function redirect(request, location, status = 302) {
|
|
722
|
+
const absoluteLocation = new URL(location, request.url).toString();
|
|
723
|
+
return new Response(null, {
|
|
724
|
+
status,
|
|
725
|
+
headers: {
|
|
726
|
+
Location: absoluteLocation,
|
|
727
|
+
"Cache-Control": "no-store"
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
function signingSecret(config) {
|
|
732
|
+
return config.secret ?? config.bypassSecret;
|
|
733
|
+
}
|
|
734
|
+
async function attachSession(response, config, method, subject, secure) {
|
|
735
|
+
const secret = signingSecret(config);
|
|
736
|
+
if (!secret) {
|
|
737
|
+
return response;
|
|
738
|
+
}
|
|
739
|
+
const token = await createSessionToken(secret, method, subject, config.sessionTtlSeconds);
|
|
740
|
+
const opts = sessionCookieOptions(config.sessionTtlSeconds, secure);
|
|
741
|
+
appendSetCookie(response, SESSION_COOKIE_NAME, token, opts);
|
|
742
|
+
return response;
|
|
743
|
+
}
|
|
744
|
+
async function handleBypass(request, config) {
|
|
745
|
+
if (!config.bypassSecret) {
|
|
746
|
+
return { kind: "miss" };
|
|
747
|
+
}
|
|
748
|
+
const token = readBypassToken(request) ?? readBypassCookie(request);
|
|
749
|
+
if (!await isValidBypass(token, config.bypassSecret)) {
|
|
750
|
+
return { kind: "miss" };
|
|
751
|
+
}
|
|
752
|
+
const setCookieMode = shouldSetBypassCookie(request);
|
|
753
|
+
const url = new URL(request.url);
|
|
754
|
+
const hadQueryBypass = url.searchParams.has(BYPASS_HEADER);
|
|
755
|
+
const hadSetCookieQuery = url.searchParams.has(SET_BYPASS_COOKIE_HEADER);
|
|
756
|
+
if (hadQueryBypass || hadSetCookieQuery || setCookieMode) {
|
|
757
|
+
url.searchParams.delete(BYPASS_HEADER);
|
|
758
|
+
url.searchParams.delete(SET_BYPASS_COOKIE_HEADER);
|
|
759
|
+
const response = redirect(request, url.pathname + url.search + url.hash);
|
|
760
|
+
const secure = isSecureRequest(request);
|
|
761
|
+
if (setCookieMode || hadSetCookieQuery) {
|
|
762
|
+
appendSetCookie(response, BYPASS_COOKIE_NAME, config.bypassSecret, {
|
|
763
|
+
httpOnly: true,
|
|
764
|
+
secure,
|
|
765
|
+
path: "/",
|
|
766
|
+
maxAge: config.sessionTtlSeconds,
|
|
767
|
+
sameSite: setCookieMode === "samesitenone" ? "none" : "lax"
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
await attachSession(response, config, "bypass", "automation", secure);
|
|
771
|
+
return { kind: "respond", response };
|
|
772
|
+
}
|
|
773
|
+
return { kind: "allow" };
|
|
774
|
+
}
|
|
775
|
+
async function handlePasswordPost(request, config) {
|
|
776
|
+
if (request.method !== "POST" || !hasPasswordAuth(config)) {
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
const contentType = request.headers.get("content-type") ?? "";
|
|
780
|
+
if (!contentType.includes("application/x-www-form-urlencoded") && !contentType.includes("multipart/form-data")) {
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
let form;
|
|
784
|
+
try {
|
|
785
|
+
form = await request.formData();
|
|
786
|
+
} catch {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
if (form.get("__becklyn_dp") !== "1") {
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
const username = String(form.get("username") ?? "");
|
|
793
|
+
const password = String(form.get("password") ?? "");
|
|
794
|
+
const returnTo = safeReturnTo(String(form.get("return_to") ?? getPathname(request)));
|
|
795
|
+
if (!await validatePasswordCredentials(config, username, password)) {
|
|
796
|
+
return htmlResponse(
|
|
797
|
+
renderLoginPage(config, {
|
|
798
|
+
error: "Invalid username or password.",
|
|
799
|
+
returnTo,
|
|
800
|
+
showPassword: true,
|
|
801
|
+
showVercel: hasVercelAuth(config)
|
|
802
|
+
})
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
const response = redirect(request, returnTo);
|
|
806
|
+
return attachSession(response, config, "password", username, isSecureRequest(request));
|
|
807
|
+
}
|
|
808
|
+
async function handleVercelAuthorize(request, config) {
|
|
809
|
+
if (getPathname(request) !== VERCEL_AUTHORIZE_PATH) {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
if (!hasVercelAuth(config)) {
|
|
813
|
+
return new Response("Sign in with Vercel is not configured", { status: 500 });
|
|
814
|
+
}
|
|
815
|
+
const returnTo = safeReturnTo(new URL(request.url).searchParams.get("return_to"));
|
|
816
|
+
if (hasVercelProxyAuth(config) && config.authProxyUrl && config.handoffSecret) {
|
|
817
|
+
const startUrl = await buildProxyStartUrl({
|
|
818
|
+
authProxyUrl: config.authProxyUrl,
|
|
819
|
+
secret: config.handoffSecret,
|
|
820
|
+
returnOrigin: new URL(request.url).origin,
|
|
821
|
+
returnPath: returnTo
|
|
822
|
+
});
|
|
823
|
+
return redirect(request, startUrl);
|
|
824
|
+
}
|
|
825
|
+
if (!hasVercelDirectAuth(config)) {
|
|
826
|
+
return new Response("Sign in with Vercel is not configured", { status: 500 });
|
|
827
|
+
}
|
|
828
|
+
return buildVercelAuthorizeRedirect(request, config, returnTo);
|
|
829
|
+
}
|
|
830
|
+
async function handleVercelCallback(request, config) {
|
|
831
|
+
if (getPathname(request) !== VERCEL_CALLBACK_PATH) {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
if (!hasVercelAuth(config) || !config.secret) {
|
|
835
|
+
return new Response("Sign in with Vercel is not configured", { status: 500 });
|
|
836
|
+
}
|
|
837
|
+
const url = new URL(request.url);
|
|
838
|
+
const handoff = url.searchParams.get("handoff");
|
|
839
|
+
const secure = isSecureRequest(request);
|
|
840
|
+
if (handoff) {
|
|
841
|
+
if (!config.handoffSecret) {
|
|
842
|
+
return new Response("Sign in with Vercel is not configured", { status: 500 });
|
|
843
|
+
}
|
|
844
|
+
const returnTo2 = safeReturnTo(url.searchParams.get("return_to"));
|
|
845
|
+
const payload = await verifyHandoffToken(config.handoffSecret, handoff, url.origin);
|
|
846
|
+
if (!payload) {
|
|
847
|
+
return htmlResponse(
|
|
848
|
+
renderLoginPage(config, {
|
|
849
|
+
error: "Vercel sign-in failed (invalid handoff).",
|
|
850
|
+
returnTo: returnTo2,
|
|
851
|
+
showPassword: hasPasswordAuth(config),
|
|
852
|
+
showVercel: true
|
|
853
|
+
})
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
const response = redirect(request, returnTo2);
|
|
857
|
+
return attachSession(response, config, "vercel", payload.subject, secure);
|
|
858
|
+
}
|
|
859
|
+
if (!hasVercelDirectAuth(config)) {
|
|
860
|
+
return new Response("Sign in with Vercel is not configured", { status: 500 });
|
|
861
|
+
}
|
|
862
|
+
const code = url.searchParams.get("code");
|
|
863
|
+
const state = url.searchParams.get("state");
|
|
864
|
+
const returnTo = safeReturnTo(readCookie(request, OAUTH_RETURN_COOKIE));
|
|
865
|
+
if (!code || !await assertOAuthState(request, state)) {
|
|
866
|
+
const response = htmlResponse(
|
|
867
|
+
renderLoginPage(config, {
|
|
868
|
+
error: "Vercel sign-in failed (invalid state).",
|
|
869
|
+
returnTo,
|
|
870
|
+
showPassword: hasPasswordAuth(config),
|
|
871
|
+
showVercel: true
|
|
872
|
+
})
|
|
873
|
+
);
|
|
874
|
+
clearOAuthCookies(response, secure);
|
|
875
|
+
return response;
|
|
876
|
+
}
|
|
877
|
+
try {
|
|
878
|
+
const codeVerifier = readCookie(request, OAUTH_VERIFIER_COOKIE);
|
|
879
|
+
if (!codeVerifier) {
|
|
880
|
+
throw new Error("Missing PKCE verifier");
|
|
881
|
+
}
|
|
882
|
+
const tokenData = await exchangeVercelCode(request, config, code, codeVerifier);
|
|
883
|
+
const storedNonce = readCookie(request, OAUTH_NONCE_COOKIE);
|
|
884
|
+
const tokenNonce = decodeIdTokenNonce(tokenData.id_token);
|
|
885
|
+
if (storedNonce && tokenNonce && !await timingSafeEqualString(storedNonce, tokenNonce)) {
|
|
886
|
+
throw new Error("Nonce mismatch");
|
|
887
|
+
}
|
|
888
|
+
const user = await fetchVercelUserInfo(tokenData.access_token);
|
|
889
|
+
const subject = user.preferred_username || user.email || user.sub || "vercel-user";
|
|
890
|
+
const response = redirect(request, returnTo);
|
|
891
|
+
clearOAuthCookies(response, secure);
|
|
892
|
+
return attachSession(response, config, "vercel", subject, secure);
|
|
893
|
+
} catch {
|
|
894
|
+
const response = htmlResponse(
|
|
895
|
+
renderLoginPage(config, {
|
|
896
|
+
error: "Vercel sign-in failed.",
|
|
897
|
+
returnTo,
|
|
898
|
+
showPassword: hasPasswordAuth(config),
|
|
899
|
+
showVercel: true
|
|
900
|
+
})
|
|
901
|
+
);
|
|
902
|
+
clearOAuthCookies(response, secure);
|
|
903
|
+
return response;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
async function handleDeploymentProtection(request, options = {}) {
|
|
907
|
+
const config = resolveConfig(options);
|
|
908
|
+
if (!isProtectionActive(config)) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
const vercelAuthorize = await handleVercelAuthorize(request, config);
|
|
912
|
+
if (vercelAuthorize) {
|
|
913
|
+
return vercelAuthorize;
|
|
914
|
+
}
|
|
915
|
+
const vercelCallback = await handleVercelCallback(request, config);
|
|
916
|
+
if (vercelCallback) {
|
|
917
|
+
return vercelCallback;
|
|
918
|
+
}
|
|
919
|
+
const passwordResponse = await handlePasswordPost(request, config);
|
|
920
|
+
if (passwordResponse) {
|
|
921
|
+
return passwordResponse;
|
|
922
|
+
}
|
|
923
|
+
const bypass = await handleBypass(request, config);
|
|
924
|
+
if (bypass.kind === "respond") {
|
|
925
|
+
return bypass.response;
|
|
926
|
+
}
|
|
927
|
+
if (bypass.kind === "allow") {
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
const secret = signingSecret(config);
|
|
931
|
+
if (secret) {
|
|
932
|
+
const session = await verifySessionToken(secret, readCookie(request, SESSION_COOKIE_NAME));
|
|
933
|
+
if (session) {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (!hasPasswordAuth(config) && !hasVercelAuth(config)) {
|
|
938
|
+
return new Response("Unauthorized", { status: 401 });
|
|
939
|
+
}
|
|
940
|
+
const returnTo = safeReturnTo(getPathname(request) + new URL(request.url).search);
|
|
941
|
+
return htmlResponse(
|
|
942
|
+
renderLoginPage(config, {
|
|
943
|
+
returnTo,
|
|
944
|
+
showPassword: hasPasswordAuth(config),
|
|
945
|
+
showVercel: hasVercelAuth(config)
|
|
946
|
+
})
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
// src/edge.ts
|
|
951
|
+
function middlewarePassThrough() {
|
|
952
|
+
return new Response(null, {
|
|
953
|
+
status: 200,
|
|
954
|
+
headers: {
|
|
955
|
+
"x-middleware-next": "1"
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
function withEdgeDeploymentProtection(options = {}) {
|
|
960
|
+
return async function deploymentProtectionEdgeMiddleware(request) {
|
|
961
|
+
const protectionResponse = await handleDeploymentProtection(request, options);
|
|
962
|
+
if (protectionResponse) {
|
|
963
|
+
return protectionResponse;
|
|
964
|
+
}
|
|
965
|
+
return middlewarePassThrough();
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
export {
|
|
969
|
+
middlewarePassThrough,
|
|
970
|
+
withEdgeDeploymentProtection
|
|
971
|
+
};
|