@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.
@@ -1 +1 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAA8B,2BAA2B,EAAE,MAAM,SAAS,CAAC;AA4RvF;;GAEG;AACH,wBAAsB,0BAA0B,CAC5C,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAqD1B"}
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"}
@@ -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
- if (!config.secret) {
46
+ const secret = signingSecret(config);
47
+ if (!secret) {
39
48
  return response;
40
49
  }
41
- const token = await createSessionToken(config.secret, method, subject, config.sessionTtlSeconds);
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("x-vercel-set-bypass-cookie");
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("x-vercel-set-bypass-cookie");
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
- if (config.secret) {
234
- const session = await verifySessionToken(config.secret, readCookie(request, SESSION_COOKIE_NAME));
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;AA8DpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,IAAI,EAAE,4BAA4B,IAgBtE,SAAS,WAAW,EACpB,QAAQ,OAAO,KAChB,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC,CActC"}
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"}
@@ -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
- // Already copied via getSetCookie when available; fall back otherwise.
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") {
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Storybook deployment protection for Vercel.
3
3
  *
4
- * Built Storybooks are static sites, so protection runs as **Vercel Edge Middleware**
4
+ * Built Storybooks are static sites, so protection runs as **Vercel Routing Middleware**
5
5
  * (not Storybook config). Drop a `middleware.ts` next to the Vercel project root that
6
6
  * serves `storybook-static` and set the same env vars as the Next.js integration.
7
7
  *
8
+ * The published `./storybook` and `./edge` entry points resolve to self-contained ESM
9
+ * bundles so Vercel edge/nodejs middleware can load them without tracing multi-file
10
+ * package internals.
11
+ *
8
12
  * @example middleware.ts
9
13
  * ```ts
10
14
  * import { withStorybookDeploymentProtection } from "@becklyn/deployment-protection/storybook";
@@ -19,7 +23,7 @@
19
23
  * };
20
24
  * ```
21
25
  *
22
- * Optional `vercel.json` for a Storybook-only project:
26
+ * Optional `vercel.json` for a Storybook-only project (`framework` must be `null`):
23
27
  * ```json
24
28
  * {
25
29
  * "buildCommand": "npm run build-storybook",
@@ -1 +1 @@
1
- {"version":3,"file":"storybook.d.ts","sourceRoot":"","sources":["../../src/storybook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,EACH,qBAAqB,EACrB,4BAA4B,IAAI,iCAAiC,GACpE,MAAM,QAAQ,CAAC;AAChB,YAAY,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"storybook.d.ts","sourceRoot":"","sources":["../../src/storybook.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,OAAO,EACH,qBAAqB,EACrB,4BAA4B,IAAI,iCAAiC,GACpE,MAAM,QAAQ,CAAC;AAChB,YAAY,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC"}
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * Storybook deployment protection for Vercel.
3
3
  *
4
- * Built Storybooks are static sites, so protection runs as **Vercel Edge Middleware**
4
+ * Built Storybooks are static sites, so protection runs as **Vercel Routing Middleware**
5
5
  * (not Storybook config). Drop a `middleware.ts` next to the Vercel project root that
6
6
  * serves `storybook-static` and set the same env vars as the Next.js integration.
7
7
  *
8
+ * The published `./storybook` and `./edge` entry points resolve to self-contained ESM
9
+ * bundles so Vercel edge/nodejs middleware can load them without tracing multi-file
10
+ * package internals.
11
+ *
8
12
  * @example middleware.ts
9
13
  * ```ts
10
14
  * import { withStorybookDeploymentProtection } from "@becklyn/deployment-protection/storybook";
@@ -19,7 +23,7 @@
19
23
  * };
20
24
  * ```
21
25
  *
22
- * Optional `vercel.json` for a Storybook-only project:
26
+ * Optional `vercel.json` for a Storybook-only project (`framework` must be `null`):
23
27
  * ```json
24
28
  * {
25
29
  * "buildCommand": "npm run build-storybook",