@becklyn/deployment-protection 0.4.1 → 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 +14 -3
- 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/edge.mjs +11 -14
- 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/storybook.mjs +11 -14
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -214,10 +214,20 @@ x-vercel-protection-bypass: <VERCEL_AUTOMATION_BYPASS_SECRET>
|
|
|
214
214
|
|
|
215
215
|
Or query:
|
|
216
216
|
|
|
217
|
+
```text
|
|
218
|
+
https://app.example/?x-vercel-protection-bypass=<secret>
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
A valid bypass query param redirects to the same path without the secret and sets the signed `__becklyn_dp_session` cookie, so later requests stay authenticated. You do not need `x-vercel-set-bypass-cookie` for that.
|
|
222
|
+
|
|
223
|
+
Optional Vercel-compatible helper — also persist the raw bypass secret as `__becklyn_dp_bypass`:
|
|
224
|
+
|
|
217
225
|
```text
|
|
218
226
|
https://app.example/?x-vercel-protection-bypass=<secret>&x-vercel-set-bypass-cookie=true
|
|
219
227
|
```
|
|
220
228
|
|
|
229
|
+
The header form stays one-shot (no cookies) so CI/Playwright can send it on each request.
|
|
230
|
+
|
|
221
231
|
When platform Deployment Protection is disabled, **this package** enforces the bypass secret so CI/Playwright keep working the same way.
|
|
222
232
|
|
|
223
233
|
## Behaviour
|
|
@@ -225,9 +235,10 @@ When platform Deployment Protection is disabled, **this package** enforces the b
|
|
|
225
235
|
1. Disabled / misconfigured → pass through
|
|
226
236
|
2. Internal Vercel OAuth routes → handled by the package (proxy start or direct OAuth / handoff)
|
|
227
237
|
3. Login form `POST` → validate username/password, set signed HttpOnly session cookie
|
|
228
|
-
4. Valid bypass header
|
|
229
|
-
5. Valid
|
|
230
|
-
6.
|
|
238
|
+
4. Valid bypass header → allow this request
|
|
239
|
+
5. Valid bypass query param → set `__becklyn_dp_session`, redirect to the cleaned URL
|
|
240
|
+
6. Valid session or bypass cookie → allow
|
|
241
|
+
7. Else → HTML login page (password and/or Sign in with Vercel)
|
|
231
242
|
|
|
232
243
|
Session cookie: `__becklyn_dp_session` (HMAC-SHA256, 14 days by default).
|
|
233
244
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAA8B,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAqSvF;;GAEG;AACH,wBAAsB,0BAA0B,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAoD1B"}
|
package/dist/cjs/handler.js
CHANGED
|
@@ -37,11 +37,20 @@ function redirect(request, location, status = 302) {
|
|
|
37
37
|
},
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Prefer the dedicated session secret; fall back to the automation bypass secret
|
|
42
|
+
* so a query-param bypass can still persist `__becklyn_dp_session` when no
|
|
43
|
+
* username/password or explicit HMAC secret is configured.
|
|
44
|
+
*/
|
|
45
|
+
function signingSecret(config) {
|
|
46
|
+
return config.secret ?? config.bypassSecret;
|
|
47
|
+
}
|
|
40
48
|
async function attachSession(response, config, method, subject, secure) {
|
|
41
|
-
|
|
49
|
+
const secret = signingSecret(config);
|
|
50
|
+
if (!secret) {
|
|
42
51
|
return response;
|
|
43
52
|
}
|
|
44
|
-
const token = await (0, session_1.createSessionToken)(
|
|
53
|
+
const token = await (0, session_1.createSessionToken)(secret, method, subject, config.sessionTtlSeconds);
|
|
45
54
|
const opts = (0, session_1.sessionCookieOptions)(config.sessionTtlSeconds, secure);
|
|
46
55
|
(0, vercel_oauth_1.appendSetCookie)(response, constants_1.SESSION_COOKIE_NAME, token, opts);
|
|
47
56
|
return response;
|
|
@@ -57,10 +66,12 @@ async function handleBypass(request, config) {
|
|
|
57
66
|
const setCookieMode = (0, bypass_1.shouldSetBypassCookie)(request);
|
|
58
67
|
const url = new URL(request.url);
|
|
59
68
|
const hadQueryBypass = url.searchParams.has(constants_1.BYPASS_HEADER);
|
|
60
|
-
const hadSetCookieQuery = url.searchParams.has(
|
|
69
|
+
const hadSetCookieQuery = url.searchParams.has(constants_1.SET_BYPASS_COOKIE_HEADER);
|
|
70
|
+
// Query-param bypass (and the Vercel set-cookie helper) persist auth, then
|
|
71
|
+
// strip secrets from the URL. Header-only automation stays one-shot.
|
|
61
72
|
if (hadQueryBypass || hadSetCookieQuery || setCookieMode) {
|
|
62
73
|
url.searchParams.delete(constants_1.BYPASS_HEADER);
|
|
63
|
-
url.searchParams.delete(
|
|
74
|
+
url.searchParams.delete(constants_1.SET_BYPASS_COOKIE_HEADER);
|
|
64
75
|
const response = redirect(request, url.pathname + url.search + url.hash);
|
|
65
76
|
const secure = isSecureRequest(request);
|
|
66
77
|
if (setCookieMode || hadSetCookieQuery) {
|
|
@@ -72,6 +83,7 @@ async function handleBypass(request, config) {
|
|
|
72
83
|
sameSite: setCookieMode === "samesitenone" ? "none" : "lax",
|
|
73
84
|
});
|
|
74
85
|
}
|
|
86
|
+
// Always persist a signed session when the bypass secret arrived via query.
|
|
75
87
|
await attachSession(response, config, "bypass", "automation", secure);
|
|
76
88
|
return { kind: "respond", response };
|
|
77
89
|
}
|
|
@@ -233,8 +245,9 @@ async function handleDeploymentProtection(request, options = {}) {
|
|
|
233
245
|
if (bypass.kind === "allow") {
|
|
234
246
|
return null;
|
|
235
247
|
}
|
|
236
|
-
|
|
237
|
-
|
|
248
|
+
const secret = signingSecret(config);
|
|
249
|
+
if (secret) {
|
|
250
|
+
const session = await (0, session_1.verifySessionToken)(secret, (0, vercel_oauth_1.readCookie)(request, constants_1.SESSION_COOKIE_NAME));
|
|
238
251
|
if (session) {
|
|
239
252
|
return null;
|
|
240
253
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,KAAK,cAAc,GAAG,CAClB,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,OAAO,KAEb,QAAQ,GACR,YAAY,GACZ,OAAO,CAAC,QAAQ,GAAG,YAAY,GAAG,SAAS,GAAG,IAAI,CAAC,GACnD,SAAS,GACT,IAAI,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAClC,EAAE,GACF,CAAC,2BAA2B,CAAC,GAC7B,CAAC,cAAc,CAAC,GAChB,CAAC,cAAc,EAAE,2BAA2B,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,KAAK,cAAc,GAAG,CAClB,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,OAAO,KAEb,QAAQ,GACR,YAAY,GACZ,OAAO,CAAC,QAAQ,GAAG,YAAY,GAAG,SAAS,GAAG,IAAI,CAAC,GACnD,SAAS,GACT,IAAI,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAClC,EAAE,GACF,CAAC,2BAA2B,CAAC,GAC7B,CAAC,cAAc,CAAC,GAChB,CAAC,cAAc,EAAE,2BAA2B,CAAC,CAAC;AA8JpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,IAAI,EAAE,4BAA4B,IAgBtE,SAAS,WAAW,EACpB,QAAQ,OAAO,KAChB,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC,CActC"}
|
package/dist/cjs/middleware.js
CHANGED
|
@@ -9,12 +9,95 @@ function isOptions(value) {
|
|
|
9
9
|
!Array.isArray(value) &&
|
|
10
10
|
typeof value !== "function");
|
|
11
11
|
}
|
|
12
|
+
function decodeCookieValue(value) {
|
|
13
|
+
try {
|
|
14
|
+
return decodeURIComponent(value);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function parseSetCookie(header) {
|
|
21
|
+
const parts = header
|
|
22
|
+
.split(";")
|
|
23
|
+
.map(part => part.trim())
|
|
24
|
+
.filter(Boolean);
|
|
25
|
+
const first = parts.shift();
|
|
26
|
+
if (!first) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const separator = first.indexOf("=");
|
|
30
|
+
if (separator <= 0) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const parsed = {
|
|
34
|
+
name: first.slice(0, separator),
|
|
35
|
+
value: decodeCookieValue(first.slice(separator + 1)),
|
|
36
|
+
};
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
const [rawKey, ...rest] = part.split("=");
|
|
39
|
+
const key = rawKey?.toLowerCase();
|
|
40
|
+
const attrValue = rest.join("=");
|
|
41
|
+
if (key === "httponly") {
|
|
42
|
+
parsed.httpOnly = true;
|
|
43
|
+
}
|
|
44
|
+
else if (key === "secure") {
|
|
45
|
+
parsed.secure = true;
|
|
46
|
+
}
|
|
47
|
+
else if (key === "path") {
|
|
48
|
+
parsed.path = attrValue;
|
|
49
|
+
}
|
|
50
|
+
else if (key === "max-age") {
|
|
51
|
+
const maxAge = Number(attrValue);
|
|
52
|
+
if (!Number.isNaN(maxAge)) {
|
|
53
|
+
parsed.maxAge = maxAge;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (key === "samesite") {
|
|
57
|
+
const sameSite = attrValue.toLowerCase();
|
|
58
|
+
if (sameSite === "lax" || sameSite === "strict" || sameSite === "none") {
|
|
59
|
+
parsed.sameSite = sameSite;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
}
|
|
65
|
+
function readSetCookieHeaders(headers) {
|
|
66
|
+
if (typeof headers.getSetCookie === "function") {
|
|
67
|
+
const cookies = headers.getSetCookie();
|
|
68
|
+
if (cookies.length > 0) {
|
|
69
|
+
return cookies;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const single = headers.get("set-cookie");
|
|
73
|
+
return single ? [single] : [];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Next.js middleware only reliably applies cookies set via `NextResponse.cookies`.
|
|
77
|
+
* Copying raw `Set-Cookie` headers onto a redirect is ignored by the runtime.
|
|
78
|
+
*/
|
|
79
|
+
function applyCookies(from, to) {
|
|
80
|
+
for (const header of readSetCookieHeaders(from.headers)) {
|
|
81
|
+
const cookie = parseSetCookie(header);
|
|
82
|
+
if (!cookie) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
to.cookies.set(cookie.name, cookie.value, {
|
|
86
|
+
httpOnly: cookie.httpOnly,
|
|
87
|
+
secure: cookie.secure,
|
|
88
|
+
path: cookie.path,
|
|
89
|
+
maxAge: cookie.maxAge,
|
|
90
|
+
sameSite: cookie.sameSite,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
12
94
|
function toNextResponse(request, response) {
|
|
13
95
|
const location = response.headers.get("Location");
|
|
14
96
|
if (location && response.status >= 300 && response.status < 400) {
|
|
15
97
|
// Always resolve against the incoming request so relative Locations never reach Next.
|
|
16
98
|
const redirectResponse = server_1.NextResponse.redirect(new URL(location, request.url), response.status);
|
|
17
99
|
copyHeaders(response, redirectResponse, /* skipLocation */ true);
|
|
100
|
+
applyCookies(response, redirectResponse);
|
|
18
101
|
return redirectResponse;
|
|
19
102
|
}
|
|
20
103
|
const nextResponse = new server_1.NextResponse(response.body, {
|
|
@@ -22,22 +105,14 @@ function toNextResponse(request, response) {
|
|
|
22
105
|
statusText: response.statusText,
|
|
23
106
|
});
|
|
24
107
|
copyHeaders(response, nextResponse, false);
|
|
108
|
+
applyCookies(response, nextResponse);
|
|
25
109
|
return nextResponse;
|
|
26
110
|
}
|
|
27
111
|
function copyHeaders(from, to, skipLocation) {
|
|
28
|
-
const setCookies = typeof from.headers.getSetCookie === "function" ? from.headers.getSetCookie() : [];
|
|
29
|
-
if (setCookies.length > 0) {
|
|
30
|
-
for (const cookie of setCookies) {
|
|
31
|
-
to.headers.append("Set-Cookie", cookie);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
112
|
from.headers.forEach((value, key) => {
|
|
35
113
|
const lower = key.toLowerCase();
|
|
36
114
|
if (lower === "set-cookie") {
|
|
37
|
-
//
|
|
38
|
-
if (setCookies.length === 0) {
|
|
39
|
-
to.headers.append("Set-Cookie", value);
|
|
40
|
-
}
|
|
115
|
+
// Applied via NextResponse.cookies so the middleware runtime honors them.
|
|
41
116
|
return;
|
|
42
117
|
}
|
|
43
118
|
if (skipLocation && lower === "location") {
|
package/dist/edge.mjs
CHANGED
|
@@ -728,16 +728,15 @@ function redirect(request, location, status = 302) {
|
|
|
728
728
|
}
|
|
729
729
|
});
|
|
730
730
|
}
|
|
731
|
+
function signingSecret(config) {
|
|
732
|
+
return config.secret ?? config.bypassSecret;
|
|
733
|
+
}
|
|
731
734
|
async function attachSession(response, config, method, subject, secure) {
|
|
732
|
-
|
|
735
|
+
const secret = signingSecret(config);
|
|
736
|
+
if (!secret) {
|
|
733
737
|
return response;
|
|
734
738
|
}
|
|
735
|
-
const token = await createSessionToken(
|
|
736
|
-
config.secret,
|
|
737
|
-
method,
|
|
738
|
-
subject,
|
|
739
|
-
config.sessionTtlSeconds
|
|
740
|
-
);
|
|
739
|
+
const token = await createSessionToken(secret, method, subject, config.sessionTtlSeconds);
|
|
741
740
|
const opts = sessionCookieOptions(config.sessionTtlSeconds, secure);
|
|
742
741
|
appendSetCookie(response, SESSION_COOKIE_NAME, token, opts);
|
|
743
742
|
return response;
|
|
@@ -753,10 +752,10 @@ async function handleBypass(request, config) {
|
|
|
753
752
|
const setCookieMode = shouldSetBypassCookie(request);
|
|
754
753
|
const url = new URL(request.url);
|
|
755
754
|
const hadQueryBypass = url.searchParams.has(BYPASS_HEADER);
|
|
756
|
-
const hadSetCookieQuery = url.searchParams.has(
|
|
755
|
+
const hadSetCookieQuery = url.searchParams.has(SET_BYPASS_COOKIE_HEADER);
|
|
757
756
|
if (hadQueryBypass || hadSetCookieQuery || setCookieMode) {
|
|
758
757
|
url.searchParams.delete(BYPASS_HEADER);
|
|
759
|
-
url.searchParams.delete(
|
|
758
|
+
url.searchParams.delete(SET_BYPASS_COOKIE_HEADER);
|
|
760
759
|
const response = redirect(request, url.pathname + url.search + url.hash);
|
|
761
760
|
const secure = isSecureRequest(request);
|
|
762
761
|
if (setCookieMode || hadSetCookieQuery) {
|
|
@@ -928,11 +927,9 @@ async function handleDeploymentProtection(request, options = {}) {
|
|
|
928
927
|
if (bypass.kind === "allow") {
|
|
929
928
|
return null;
|
|
930
929
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
readCookie(request, SESSION_COOKIE_NAME)
|
|
935
|
-
);
|
|
930
|
+
const secret = signingSecret(config);
|
|
931
|
+
if (secret) {
|
|
932
|
+
const session = await verifySessionToken(secret, readCookie(request, SESSION_COOKIE_NAME));
|
|
936
933
|
if (session) {
|
|
937
934
|
return null;
|
|
938
935
|
}
|
package/dist/es/handler.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"AA0BA,OAAO,KAAK,EAA8B,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAqSvF;;GAEG;AACH,wBAAsB,0BAA0B,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAoD1B"}
|
package/dist/es/handler.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isValidBypass, readBypassCookie, readBypassToken, shouldSetBypassCookie } from "./bypass";
|
|
2
2
|
import { hasPasswordAuth, hasVercelAuth, hasVercelDirectAuth, hasVercelProxyAuth, isProtectionActive, resolveConfig, } from "./config";
|
|
3
|
-
import { BYPASS_COOKIE_NAME, BYPASS_HEADER, OAUTH_NONCE_COOKIE, OAUTH_RETURN_COOKIE, OAUTH_VERIFIER_COOKIE, SESSION_COOKIE_NAME, VERCEL_AUTHORIZE_PATH, VERCEL_CALLBACK_PATH, } from "./constants";
|
|
3
|
+
import { BYPASS_COOKIE_NAME, BYPASS_HEADER, OAUTH_NONCE_COOKIE, OAUTH_RETURN_COOKIE, OAUTH_VERIFIER_COOKIE, SESSION_COOKIE_NAME, SET_BYPASS_COOKIE_HEADER, VERCEL_AUTHORIZE_PATH, VERCEL_CALLBACK_PATH, } from "./constants";
|
|
4
4
|
import { timingSafeEqualString } from "./crypto";
|
|
5
5
|
import { buildProxyStartUrl, verifyHandoffToken } from "./handoff";
|
|
6
6
|
import { renderLoginPage } from "./login-page";
|
|
@@ -34,11 +34,20 @@ function redirect(request, location, status = 302) {
|
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Prefer the dedicated session secret; fall back to the automation bypass secret
|
|
39
|
+
* so a query-param bypass can still persist `__becklyn_dp_session` when no
|
|
40
|
+
* username/password or explicit HMAC secret is configured.
|
|
41
|
+
*/
|
|
42
|
+
function signingSecret(config) {
|
|
43
|
+
return config.secret ?? config.bypassSecret;
|
|
44
|
+
}
|
|
37
45
|
async function attachSession(response, config, method, subject, secure) {
|
|
38
|
-
|
|
46
|
+
const secret = signingSecret(config);
|
|
47
|
+
if (!secret) {
|
|
39
48
|
return response;
|
|
40
49
|
}
|
|
41
|
-
const token = await createSessionToken(
|
|
50
|
+
const token = await createSessionToken(secret, method, subject, config.sessionTtlSeconds);
|
|
42
51
|
const opts = sessionCookieOptions(config.sessionTtlSeconds, secure);
|
|
43
52
|
appendSetCookie(response, SESSION_COOKIE_NAME, token, opts);
|
|
44
53
|
return response;
|
|
@@ -54,10 +63,12 @@ async function handleBypass(request, config) {
|
|
|
54
63
|
const setCookieMode = shouldSetBypassCookie(request);
|
|
55
64
|
const url = new URL(request.url);
|
|
56
65
|
const hadQueryBypass = url.searchParams.has(BYPASS_HEADER);
|
|
57
|
-
const hadSetCookieQuery = url.searchParams.has(
|
|
66
|
+
const hadSetCookieQuery = url.searchParams.has(SET_BYPASS_COOKIE_HEADER);
|
|
67
|
+
// Query-param bypass (and the Vercel set-cookie helper) persist auth, then
|
|
68
|
+
// strip secrets from the URL. Header-only automation stays one-shot.
|
|
58
69
|
if (hadQueryBypass || hadSetCookieQuery || setCookieMode) {
|
|
59
70
|
url.searchParams.delete(BYPASS_HEADER);
|
|
60
|
-
url.searchParams.delete(
|
|
71
|
+
url.searchParams.delete(SET_BYPASS_COOKIE_HEADER);
|
|
61
72
|
const response = redirect(request, url.pathname + url.search + url.hash);
|
|
62
73
|
const secure = isSecureRequest(request);
|
|
63
74
|
if (setCookieMode || hadSetCookieQuery) {
|
|
@@ -69,6 +80,7 @@ async function handleBypass(request, config) {
|
|
|
69
80
|
sameSite: setCookieMode === "samesitenone" ? "none" : "lax",
|
|
70
81
|
});
|
|
71
82
|
}
|
|
83
|
+
// Always persist a signed session when the bypass secret arrived via query.
|
|
72
84
|
await attachSession(response, config, "bypass", "automation", secure);
|
|
73
85
|
return { kind: "respond", response };
|
|
74
86
|
}
|
|
@@ -230,8 +242,9 @@ export async function handleDeploymentProtection(request, options = {}) {
|
|
|
230
242
|
if (bypass.kind === "allow") {
|
|
231
243
|
return null;
|
|
232
244
|
}
|
|
233
|
-
|
|
234
|
-
|
|
245
|
+
const secret = signingSecret(config);
|
|
246
|
+
if (secret) {
|
|
247
|
+
const session = await verifySessionToken(secret, readCookie(request, SESSION_COOKIE_NAME));
|
|
235
248
|
if (session) {
|
|
236
249
|
return null;
|
|
237
250
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,KAAK,cAAc,GAAG,CAClB,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,OAAO,KAEb,QAAQ,GACR,YAAY,GACZ,OAAO,CAAC,QAAQ,GAAG,YAAY,GAAG,SAAS,GAAG,IAAI,CAAC,GACnD,SAAS,GACT,IAAI,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAClC,EAAE,GACF,CAAC,2BAA2B,CAAC,GAC7B,CAAC,cAAc,CAAC,GAChB,CAAC,cAAc,EAAE,2BAA2B,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAE3D,KAAK,cAAc,GAAG,CAClB,OAAO,EAAE,WAAW,EACpB,KAAK,CAAC,EAAE,OAAO,KAEb,QAAQ,GACR,YAAY,GACZ,OAAO,CAAC,QAAQ,GAAG,YAAY,GAAG,SAAS,GAAG,IAAI,CAAC,GACnD,SAAS,GACT,IAAI,CAAC;AAEX,MAAM,MAAM,4BAA4B,GAClC,EAAE,GACF,CAAC,2BAA2B,CAAC,GAC7B,CAAC,cAAc,CAAC,GAChB,CAAC,cAAc,EAAE,2BAA2B,CAAC,CAAC;AA8JpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,IAAI,EAAE,4BAA4B,IAgBtE,SAAS,WAAW,EACpB,QAAQ,OAAO,KAChB,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC,CActC"}
|
package/dist/es/middleware.js
CHANGED
|
@@ -6,12 +6,95 @@ function isOptions(value) {
|
|
|
6
6
|
!Array.isArray(value) &&
|
|
7
7
|
typeof value !== "function");
|
|
8
8
|
}
|
|
9
|
+
function decodeCookieValue(value) {
|
|
10
|
+
try {
|
|
11
|
+
return decodeURIComponent(value);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function parseSetCookie(header) {
|
|
18
|
+
const parts = header
|
|
19
|
+
.split(";")
|
|
20
|
+
.map(part => part.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
const first = parts.shift();
|
|
23
|
+
if (!first) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const separator = first.indexOf("=");
|
|
27
|
+
if (separator <= 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const parsed = {
|
|
31
|
+
name: first.slice(0, separator),
|
|
32
|
+
value: decodeCookieValue(first.slice(separator + 1)),
|
|
33
|
+
};
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
const [rawKey, ...rest] = part.split("=");
|
|
36
|
+
const key = rawKey?.toLowerCase();
|
|
37
|
+
const attrValue = rest.join("=");
|
|
38
|
+
if (key === "httponly") {
|
|
39
|
+
parsed.httpOnly = true;
|
|
40
|
+
}
|
|
41
|
+
else if (key === "secure") {
|
|
42
|
+
parsed.secure = true;
|
|
43
|
+
}
|
|
44
|
+
else if (key === "path") {
|
|
45
|
+
parsed.path = attrValue;
|
|
46
|
+
}
|
|
47
|
+
else if (key === "max-age") {
|
|
48
|
+
const maxAge = Number(attrValue);
|
|
49
|
+
if (!Number.isNaN(maxAge)) {
|
|
50
|
+
parsed.maxAge = maxAge;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else if (key === "samesite") {
|
|
54
|
+
const sameSite = attrValue.toLowerCase();
|
|
55
|
+
if (sameSite === "lax" || sameSite === "strict" || sameSite === "none") {
|
|
56
|
+
parsed.sameSite = sameSite;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
function readSetCookieHeaders(headers) {
|
|
63
|
+
if (typeof headers.getSetCookie === "function") {
|
|
64
|
+
const cookies = headers.getSetCookie();
|
|
65
|
+
if (cookies.length > 0) {
|
|
66
|
+
return cookies;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const single = headers.get("set-cookie");
|
|
70
|
+
return single ? [single] : [];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Next.js middleware only reliably applies cookies set via `NextResponse.cookies`.
|
|
74
|
+
* Copying raw `Set-Cookie` headers onto a redirect is ignored by the runtime.
|
|
75
|
+
*/
|
|
76
|
+
function applyCookies(from, to) {
|
|
77
|
+
for (const header of readSetCookieHeaders(from.headers)) {
|
|
78
|
+
const cookie = parseSetCookie(header);
|
|
79
|
+
if (!cookie) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
to.cookies.set(cookie.name, cookie.value, {
|
|
83
|
+
httpOnly: cookie.httpOnly,
|
|
84
|
+
secure: cookie.secure,
|
|
85
|
+
path: cookie.path,
|
|
86
|
+
maxAge: cookie.maxAge,
|
|
87
|
+
sameSite: cookie.sameSite,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
9
91
|
function toNextResponse(request, response) {
|
|
10
92
|
const location = response.headers.get("Location");
|
|
11
93
|
if (location && response.status >= 300 && response.status < 400) {
|
|
12
94
|
// Always resolve against the incoming request so relative Locations never reach Next.
|
|
13
95
|
const redirectResponse = NextResponse.redirect(new URL(location, request.url), response.status);
|
|
14
96
|
copyHeaders(response, redirectResponse, /* skipLocation */ true);
|
|
97
|
+
applyCookies(response, redirectResponse);
|
|
15
98
|
return redirectResponse;
|
|
16
99
|
}
|
|
17
100
|
const nextResponse = new NextResponse(response.body, {
|
|
@@ -19,22 +102,14 @@ function toNextResponse(request, response) {
|
|
|
19
102
|
statusText: response.statusText,
|
|
20
103
|
});
|
|
21
104
|
copyHeaders(response, nextResponse, false);
|
|
105
|
+
applyCookies(response, nextResponse);
|
|
22
106
|
return nextResponse;
|
|
23
107
|
}
|
|
24
108
|
function copyHeaders(from, to, skipLocation) {
|
|
25
|
-
const setCookies = typeof from.headers.getSetCookie === "function" ? from.headers.getSetCookie() : [];
|
|
26
|
-
if (setCookies.length > 0) {
|
|
27
|
-
for (const cookie of setCookies) {
|
|
28
|
-
to.headers.append("Set-Cookie", cookie);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
109
|
from.headers.forEach((value, key) => {
|
|
32
110
|
const lower = key.toLowerCase();
|
|
33
111
|
if (lower === "set-cookie") {
|
|
34
|
-
//
|
|
35
|
-
if (setCookies.length === 0) {
|
|
36
|
-
to.headers.append("Set-Cookie", value);
|
|
37
|
-
}
|
|
112
|
+
// Applied via NextResponse.cookies so the middleware runtime honors them.
|
|
38
113
|
return;
|
|
39
114
|
}
|
|
40
115
|
if (skipLocation && lower === "location") {
|
package/dist/storybook.mjs
CHANGED
|
@@ -728,16 +728,15 @@ function redirect(request, location, status = 302) {
|
|
|
728
728
|
}
|
|
729
729
|
});
|
|
730
730
|
}
|
|
731
|
+
function signingSecret(config) {
|
|
732
|
+
return config.secret ?? config.bypassSecret;
|
|
733
|
+
}
|
|
731
734
|
async function attachSession(response, config, method, subject, secure) {
|
|
732
|
-
|
|
735
|
+
const secret = signingSecret(config);
|
|
736
|
+
if (!secret) {
|
|
733
737
|
return response;
|
|
734
738
|
}
|
|
735
|
-
const token = await createSessionToken(
|
|
736
|
-
config.secret,
|
|
737
|
-
method,
|
|
738
|
-
subject,
|
|
739
|
-
config.sessionTtlSeconds
|
|
740
|
-
);
|
|
739
|
+
const token = await createSessionToken(secret, method, subject, config.sessionTtlSeconds);
|
|
741
740
|
const opts = sessionCookieOptions(config.sessionTtlSeconds, secure);
|
|
742
741
|
appendSetCookie(response, SESSION_COOKIE_NAME, token, opts);
|
|
743
742
|
return response;
|
|
@@ -753,10 +752,10 @@ async function handleBypass(request, config) {
|
|
|
753
752
|
const setCookieMode = shouldSetBypassCookie(request);
|
|
754
753
|
const url = new URL(request.url);
|
|
755
754
|
const hadQueryBypass = url.searchParams.has(BYPASS_HEADER);
|
|
756
|
-
const hadSetCookieQuery = url.searchParams.has(
|
|
755
|
+
const hadSetCookieQuery = url.searchParams.has(SET_BYPASS_COOKIE_HEADER);
|
|
757
756
|
if (hadQueryBypass || hadSetCookieQuery || setCookieMode) {
|
|
758
757
|
url.searchParams.delete(BYPASS_HEADER);
|
|
759
|
-
url.searchParams.delete(
|
|
758
|
+
url.searchParams.delete(SET_BYPASS_COOKIE_HEADER);
|
|
760
759
|
const response = redirect(request, url.pathname + url.search + url.hash);
|
|
761
760
|
const secure = isSecureRequest(request);
|
|
762
761
|
if (setCookieMode || hadSetCookieQuery) {
|
|
@@ -928,11 +927,9 @@ async function handleDeploymentProtection(request, options = {}) {
|
|
|
928
927
|
if (bypass.kind === "allow") {
|
|
929
928
|
return null;
|
|
930
929
|
}
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
readCookie(request, SESSION_COOKIE_NAME)
|
|
935
|
-
);
|
|
930
|
+
const secret = signingSecret(config);
|
|
931
|
+
if (secret) {
|
|
932
|
+
const session = await verifySessionToken(secret, readCookie(request, SESSION_COOKIE_NAME));
|
|
936
933
|
if (session) {
|
|
937
934
|
return null;
|
|
938
935
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@becklyn/deployment-protection",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Simple shared username/password + Sign in with Vercel gate for Next.js and Storybook deployments",
|
|
6
6
|
"homepage": "https://github.com/Becklyn-Studios/ts-libs/tree/main/packages/deployment-protection",
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"lint": "eslint . --max-warnings 0 && prettier --check \"./**/*.{ts,tsx,md,json,mjs}\"",
|
|
13
13
|
"fix": "eslint . --fix && prettier --write \"./**/*.{ts,tsx,md,json,mjs}\"",
|
|
14
|
-
"test": "
|
|
14
|
+
"test": "vitest run",
|
|
15
15
|
"build": "rm -rf dist && tsc -p tsconfig-es.json && tsc -p tsconfig-cjs.json && node ./scripts/build-edge-bundles.mjs",
|
|
16
|
-
"prepublishOnly": "npm run lint && npm run test"
|
|
16
|
+
"prepublishOnly": "npm run lint && npm run build && npm run test"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@becklyn/eslint": "*",
|