@becklyn/deployment-protection 0.2.0 → 0.3.0

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.
Files changed (46) hide show
  1. package/README.md +81 -12
  2. package/dist/cjs/auth-proxy.d.ts +15 -0
  3. package/dist/cjs/auth-proxy.d.ts.map +1 -0
  4. package/dist/cjs/auth-proxy.js +135 -0
  5. package/dist/cjs/config.d.ts +7 -0
  6. package/dist/cjs/config.d.ts.map +1 -1
  7. package/dist/cjs/config.js +20 -1
  8. package/dist/cjs/constants.d.ts +1 -0
  9. package/dist/cjs/constants.d.ts.map +1 -1
  10. package/dist/cjs/constants.js +2 -1
  11. package/dist/cjs/handler.d.ts.map +1 -1
  12. package/dist/cjs/handler.js +38 -1
  13. package/dist/cjs/handoff.d.ts +46 -0
  14. package/dist/cjs/handoff.d.ts.map +1 -0
  15. package/dist/cjs/handoff.js +199 -0
  16. package/dist/cjs/index.d.ts +2 -1
  17. package/dist/cjs/index.d.ts.map +1 -1
  18. package/dist/cjs/index.js +8 -1
  19. package/dist/cjs/types.d.ts +15 -0
  20. package/dist/cjs/types.d.ts.map +1 -1
  21. package/dist/cjs/vercel-oauth.d.ts +9 -2
  22. package/dist/cjs/vercel-oauth.d.ts.map +1 -1
  23. package/dist/cjs/vercel-oauth.js +8 -4
  24. package/dist/es/auth-proxy.d.ts +15 -0
  25. package/dist/es/auth-proxy.d.ts.map +1 -0
  26. package/dist/es/auth-proxy.js +130 -0
  27. package/dist/es/config.d.ts +7 -0
  28. package/dist/es/config.d.ts.map +1 -1
  29. package/dist/es/config.js +18 -1
  30. package/dist/es/constants.d.ts +1 -0
  31. package/dist/es/constants.d.ts.map +1 -1
  32. package/dist/es/constants.js +1 -0
  33. package/dist/es/handler.d.ts.map +1 -1
  34. package/dist/es/handler.js +39 -2
  35. package/dist/es/handoff.d.ts +46 -0
  36. package/dist/es/handoff.d.ts.map +1 -0
  37. package/dist/es/handoff.js +187 -0
  38. package/dist/es/index.d.ts +2 -1
  39. package/dist/es/index.d.ts.map +1 -1
  40. package/dist/es/index.js +2 -1
  41. package/dist/es/types.d.ts +15 -0
  42. package/dist/es/types.d.ts.map +1 -1
  43. package/dist/es/vercel-oauth.d.ts +9 -2
  44. package/dist/es/vercel-oauth.d.ts.map +1 -1
  45. package/dist/es/vercel-oauth.js +9 -5
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -70,33 +70,97 @@ export default withDeploymentProtection(async request => {
70
70
  | `DEPLOYMENT_PROTECTION_ENABLED` | no | `true` | Set `false` / `0` / `off` to disable |
71
71
  | `DEPLOYMENT_PROTECTION_USERNAME` | for password auth | — | Shared username |
72
72
  | `DEPLOYMENT_PROTECTION_PASSWORD` | for password auth | — | Shared password |
73
- | `DEPLOYMENT_PROTECTION_SECRET` | recommended | derived from user+pass | HMAC secret for session cookies |
73
+ | `DEPLOYMENT_PROTECTION_SECRET` | recommended | derived from user+pass | HMAC secret for session cookies (+ handoff fallback) |
74
+ | `DEPLOYMENT_PROTECTION_AUTH_PROXY_URL` | for proxy SSO | — | Base URL of the central auth-proxy app |
75
+ | `DEPLOYMENT_PROTECTION_HANDOFF_SECRET` | no | same as `…_SECRET` | Shared secret between apps and the auth-proxy |
74
76
  | `VERCEL_AUTOMATION_BYPASS_SECRET` | no | — | Same secret as Vercel Protection Bypass for Automation |
75
- | `DEPLOYMENT_PROTECTION_VERCEL_CLIENT_ID` | for Vercel login | — | Or `NEXT_PUBLIC_VERCEL_APP_CLIENT_ID` |
76
- | `DEPLOYMENT_PROTECTION_VERCEL_CLIENT_SECRET` | for Vercel login | — | Or `VERCEL_APP_CLIENT_SECRET` |
77
+ | `DEPLOYMENT_PROTECTION_VERCEL_CLIENT_ID` | direct Vercel SSO | — | Or `NEXT_PUBLIC_VERCEL_APP_CLIENT_ID` (legacy) |
78
+ | `DEPLOYMENT_PROTECTION_VERCEL_CLIENT_SECRET` | direct Vercel SSO | — | Or `VERCEL_APP_CLIENT_SECRET` (legacy) |
77
79
  | `DEPLOYMENT_PROTECTION_FORM_TITLE` | no | `Authentication required` | Login heading |
78
80
  | `DEPLOYMENT_PROTECTION_FORM_DESCRIPTION` | no | … | Login description |
79
81
 
80
- At least one of password auth, Vercel OAuth, or a bypass secret must be configured while enabled. If nothing is configured, the gate stays inactive (fail-open) so local dev is not bricked.
82
+ At least one of password auth, Vercel OAuth (proxy or direct), or a bypass secret must be configured while enabled. If nothing is configured, the gate stays inactive (fail-open) so local dev is not bricked.
81
83
 
82
- ### 3. Sign in with Vercel (optional)
84
+ ### 3. Sign in with Vercel via auth-proxy (recommended)
83
85
 
84
- 1. Create a [Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel) app in the Vercel dashboard.
85
- 2. Generate a client secret.
86
- 3. Add authorization callback URLs for each project host, pointing at:
86
+ Register **one** OAuth callback URL on a shared [Sign in with Vercel](https://vercel.com/docs/sign-in-with-vercel) app:
87
+
88
+ ```text
89
+ https://<auth-proxy-host>/callback
90
+ ```
91
+
92
+ Deploy the `deployment-protection-auth-proxy` app from this monorepo (or any tiny Next app that wires the handlers below) and set:
93
+
94
+ **On the auth-proxy**
95
+
96
+ | Variable | Description |
97
+ | ---------------------------------------------------- | -------------------------------------- |
98
+ | `DEPLOYMENT_PROTECTION_VERCEL_CLIENT_ID` | Vercel OAuth client id |
99
+ | `DEPLOYMENT_PROTECTION_VERCEL_CLIENT_SECRET` | Vercel OAuth client secret |
100
+ | `DEPLOYMENT_PROTECTION_SECRET` or `…_HANDOFF_SECRET` | Shared HMAC secret with protected apps |
101
+ | `DEPLOYMENT_PROTECTION_ALLOWED_ORIGINS` | Optional allowlist (see below) |
102
+
103
+ **On every protected app**
104
+
105
+ | Variable | Description |
106
+ | -------------------------------------- | -------------------------------------- |
107
+ | `DEPLOYMENT_PROTECTION_AUTH_PROXY_URL` | e.g. `https://dp-auth.example.com` |
108
+ | `DEPLOYMENT_PROTECTION_SECRET` | Session cookie secret |
109
+ | `DEPLOYMENT_PROTECTION_HANDOFF_SECRET` | Same as proxy (defaults to `…_SECRET`) |
110
+
111
+ No per-project / per-preview callback URLs.
112
+
113
+ #### Flow
114
+
115
+ 1. App redirects to `{AUTH_PROXY_URL}/start` with a **signed** `return_origin` + `return_path`.
116
+ 2. Proxy runs Vercel OAuth against its fixed `/callback`.
117
+ 3. Proxy redirects to
118
+ `{return_origin}/_becklyn/deployment-protection/vercel/callback?handoff=…&return_to=…`
119
+ 4. App verifies the short-lived handoff token (`aud` must match its origin), sets `__becklyn_dp_session`, continues.
120
+
121
+ #### Allowlist (proxy)
122
+
123
+ `DEPLOYMENT_PROTECTION_ALLOWED_ORIGINS` is a comma-separated list:
124
+
125
+ - full origins: `https://app.example.com`
126
+ - host suffixes: `.vercel.app`, `example.com`
127
+ - `localhost` (http(s) localhost / 127.0.0.1)
128
+
129
+ Empty allowlist → any origin that passes https (or local http) validation. Prefer an allowlist in production.
130
+
131
+ #### Minimal proxy route handlers
132
+
133
+ ```ts
134
+ // app/start/route.ts
135
+ import { handleAuthProxyStart } from "@becklyn/deployment-protection";
136
+
137
+ export const GET = (request: Request) => handleAuthProxyStart(request);
138
+
139
+ // app/callback/route.ts
140
+ import { handleAuthProxyCallback } from "@becklyn/deployment-protection";
141
+
142
+ export const GET = (request: Request) => handleAuthProxyCallback(request);
143
+ ```
144
+
145
+ ### 4. Sign in with Vercel (legacy direct mode)
146
+
147
+ Still supported when `DEPLOYMENT_PROTECTION_AUTH_PROXY_URL` is unset:
148
+
149
+ 1. Create a Sign in with Vercel app.
150
+ 2. Add authorization callback URLs for **each** project host:
87
151
 
88
152
  ```text
89
153
  https://<your-host>/_becklyn/deployment-protection/vercel/callback
90
154
  http://localhost:3000/_becklyn/deployment-protection/vercel/callback
91
155
  ```
92
156
 
93
- You can attach the path to preview + production domains of each project from the dashboard.
157
+ 3. Set client id/secret on the project(s).
94
158
 
95
- 4. Set client id/secret env vars on the project(s). Prefer team-level shared env values.
159
+ When both proxy URL and direct client credentials are set, **proxy mode wins** for the authorize step.
96
160
 
97
161
  Scopes used: `openid email profile`.
98
162
 
99
- ### 4. Automation bypass
163
+ ### 5. Automation bypass
100
164
 
101
165
  Matches Vercel’s Protection Bypass for Automation:
102
166
 
@@ -116,7 +180,7 @@ When platform Deployment Protection is disabled, **this package** enforces the b
116
180
  ## Behaviour
117
181
 
118
182
  1. Disabled / misconfigured → pass through
119
- 2. Internal Vercel OAuth routes → handled by the package
183
+ 2. Internal Vercel OAuth routes → handled by the package (proxy start or direct OAuth / handoff)
120
184
  3. Login form `POST` → validate username/password, set signed HttpOnly session cookie
121
185
  4. Valid bypass header/query/cookie → allow (optionally set cookies)
122
186
  5. Valid session cookie → allow
@@ -136,6 +200,8 @@ Redeploy. Middleware stays installed but is a no-op.
136
200
 
137
201
  ```ts
138
202
  import {
203
+ handleAuthProxyCallback,
204
+ handleAuthProxyStart,
139
205
  handleDeploymentProtection,
140
206
  resolveConfig,
141
207
  withDeploymentProtection,
@@ -144,6 +210,7 @@ import {
144
210
 
145
211
  - `withDeploymentProtection(options?)` / `withDeploymentProtection(next, options?)` — middleware/proxy factory
146
212
  - `handleDeploymentProtection(request, options?)` — framework-agnostic core (`null` = continue)
213
+ - `handleAuthProxyStart` / `handleAuthProxyCallback` — central auth-proxy routes
147
214
 
148
215
  Recommended matcher (must be pasted as a string literal in your middleware/proxy file — see above):
149
216
 
@@ -155,6 +222,8 @@ Recommended matcher (must be pasted as a string literal in your middleware/proxy
155
222
 
156
223
  - This is a **shared-secret gate**, not per-user product auth.
157
224
  - Prefer a dedicated `DEPLOYMENT_PROTECTION_SECRET` in production.
225
+ - Auth-proxy start requests are HMAC-signed; handoff tokens are short-lived and audience-bound.
226
+ - Do not put the raw handoff secret in query strings — only signatures/tokens.
158
227
  - Turn off paid Vercel Password Protection / seat-heavy sharing once this is live; keep `VERCEL_AUTOMATION_BYPASS_SECRET` configured for tooling.
159
228
  - Middleware is a convenience edge check — do not treat it as the only control for highly sensitive data.
160
229
 
@@ -0,0 +1,15 @@
1
+ import type { DeploymentProtectionOptions } from "./types";
2
+ /** Fixed OAuth callback path registered once on the Vercel OAuth app. */
3
+ export declare const AUTH_PROXY_CALLBACK_PATH = "/callback";
4
+ export declare const AUTH_PROXY_START_PATH = "/start";
5
+ /**
6
+ * Auth-proxy entry: verify the signed start request from a protected app, then
7
+ * begin Sign in with Vercel against this proxy's fixed callback URL.
8
+ */
9
+ export declare function handleAuthProxyStart(request: Request, options?: DeploymentProtectionOptions): Promise<Response>;
10
+ /**
11
+ * Auth-proxy Vercel OAuth callback: exchange the code, mint a short-lived handoff
12
+ * token, and redirect back to the protected app callback.
13
+ */
14
+ export declare function handleAuthProxyCallback(request: Request, options?: DeploymentProtectionOptions): Promise<Response>;
15
+ //# sourceMappingURL=auth-proxy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth-proxy.d.ts","sourceRoot":"","sources":["../../src/auth-proxy.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAY3D,yEAAyE;AACzE,eAAO,MAAM,wBAAwB,cAAc,CAAC;AACpD,eAAO,MAAM,qBAAqB,WAAW,CAAC;AA0B9C;;;GAGG;AACH,wBAAsB,oBAAoB,CACtC,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,CAAC,CAwCnB;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CACzC,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,2BAAgC,GAC1C,OAAO,CAAC,QAAQ,CAAC,CAqEnB"}
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AUTH_PROXY_START_PATH = exports.AUTH_PROXY_CALLBACK_PATH = void 0;
4
+ exports.handleAuthProxyStart = handleAuthProxyStart;
5
+ exports.handleAuthProxyCallback = handleAuthProxyCallback;
6
+ const config_1 = require("./config");
7
+ const constants_1 = require("./constants");
8
+ const crypto_1 = require("./crypto");
9
+ const handoff_1 = require("./handoff");
10
+ const safe_return_to_1 = require("./safe-return-to");
11
+ const vercel_oauth_1 = require("./vercel-oauth");
12
+ /** Fixed OAuth callback path registered once on the Vercel OAuth app. */
13
+ exports.AUTH_PROXY_CALLBACK_PATH = "/callback";
14
+ exports.AUTH_PROXY_START_PATH = "/start";
15
+ function isSecureRequest(request) {
16
+ return new URL(request.url).protocol === "https:";
17
+ }
18
+ function textResponse(message, status) {
19
+ return new Response(message, {
20
+ status,
21
+ headers: {
22
+ "Content-Type": "text/plain; charset=utf-8",
23
+ "Cache-Control": "no-store",
24
+ },
25
+ });
26
+ }
27
+ function cookieOptions(secure) {
28
+ return {
29
+ httpOnly: true,
30
+ sameSite: "lax",
31
+ secure,
32
+ path: "/",
33
+ maxAge: 10 * 60,
34
+ };
35
+ }
36
+ /**
37
+ * Auth-proxy entry: verify the signed start request from a protected app, then
38
+ * begin Sign in with Vercel against this proxy's fixed callback URL.
39
+ */
40
+ async function handleAuthProxyStart(request, options = {}) {
41
+ const config = (0, config_1.resolveConfig)(options);
42
+ if (!(0, config_1.hasVercelDirectAuth)(config) || !config.handoffSecret) {
43
+ return textResponse("Auth proxy is not configured", 500);
44
+ }
45
+ const url = new URL(request.url);
46
+ const returnOrigin = (0, handoff_1.normalizeReturnOrigin)(url.searchParams.get("return_origin"));
47
+ const returnPath = (0, safe_return_to_1.safeReturnTo)(url.searchParams.get("return_path"));
48
+ const expRaw = url.searchParams.get("exp");
49
+ const nonce = url.searchParams.get("nonce");
50
+ const sig = url.searchParams.get("sig");
51
+ const exp = expRaw ? Number(expRaw) : NaN;
52
+ if (!returnOrigin || !nonce || !Number.isFinite(exp)) {
53
+ return textResponse("Invalid start request", 400);
54
+ }
55
+ if (!(0, handoff_1.isReturnOriginAllowed)(returnOrigin, config.allowedReturnOrigins)) {
56
+ return textResponse("Return origin is not allowed", 403);
57
+ }
58
+ const params = {
59
+ returnOrigin,
60
+ returnPath,
61
+ exp,
62
+ nonce,
63
+ };
64
+ if (!(await (0, handoff_1.verifyProxyStartSignature)(config.handoffSecret, params, sig))) {
65
+ return textResponse("Invalid or expired start signature", 403);
66
+ }
67
+ const response = await (0, vercel_oauth_1.buildVercelAuthorizeRedirect)(request, config, returnPath, {
68
+ callbackPath: exports.AUTH_PROXY_CALLBACK_PATH,
69
+ });
70
+ const secure = isSecureRequest(request);
71
+ (0, vercel_oauth_1.appendSetCookie)(response, constants_1.OAUTH_RETURN_ORIGIN_COOKIE, returnOrigin, cookieOptions(secure));
72
+ return response;
73
+ }
74
+ /**
75
+ * Auth-proxy Vercel OAuth callback: exchange the code, mint a short-lived handoff
76
+ * token, and redirect back to the protected app callback.
77
+ */
78
+ async function handleAuthProxyCallback(request, options = {}) {
79
+ const config = (0, config_1.resolveConfig)(options);
80
+ if (!(0, config_1.hasVercelDirectAuth)(config) || !config.handoffSecret) {
81
+ return textResponse("Auth proxy is not configured", 500);
82
+ }
83
+ const url = new URL(request.url);
84
+ const code = url.searchParams.get("code");
85
+ const state = url.searchParams.get("state");
86
+ const secure = isSecureRequest(request);
87
+ const returnOrigin = (0, handoff_1.normalizeReturnOrigin)((0, vercel_oauth_1.readCookie)(request, constants_1.OAUTH_RETURN_ORIGIN_COOKIE));
88
+ const returnPath = (0, safe_return_to_1.safeReturnTo)((0, vercel_oauth_1.readCookie)(request, constants_1.OAUTH_RETURN_COOKIE));
89
+ const fail = (message, status = 401) => {
90
+ const response = textResponse(message, status);
91
+ (0, vercel_oauth_1.clearOAuthCookies)(response, secure);
92
+ return response;
93
+ };
94
+ if (!returnOrigin) {
95
+ return fail("Missing return origin", 400);
96
+ }
97
+ if (!(0, handoff_1.isReturnOriginAllowed)(returnOrigin, config.allowedReturnOrigins)) {
98
+ return fail("Return origin is not allowed", 403);
99
+ }
100
+ if (!code || !(await (0, vercel_oauth_1.assertOAuthState)(request, state))) {
101
+ return fail("Vercel sign-in failed (invalid state)");
102
+ }
103
+ try {
104
+ const codeVerifier = (0, vercel_oauth_1.readCookie)(request, constants_1.OAUTH_VERIFIER_COOKIE);
105
+ if (!codeVerifier) {
106
+ throw new Error("Missing PKCE verifier");
107
+ }
108
+ const tokenData = await (0, vercel_oauth_1.exchangeVercelCode)(request, config, code, codeVerifier, {
109
+ callbackPath: exports.AUTH_PROXY_CALLBACK_PATH,
110
+ });
111
+ const storedNonce = (0, vercel_oauth_1.readCookie)(request, constants_1.OAUTH_NONCE_COOKIE);
112
+ const tokenNonce = (0, vercel_oauth_1.decodeIdTokenNonce)(tokenData.id_token);
113
+ if (storedNonce && tokenNonce && !(await (0, crypto_1.timingSafeEqualString)(storedNonce, tokenNonce))) {
114
+ throw new Error("Nonce mismatch");
115
+ }
116
+ const user = await (0, vercel_oauth_1.fetchVercelUserInfo)(tokenData.access_token);
117
+ const subject = user.preferred_username || user.email || user.sub || "vercel-user";
118
+ const handoff = await (0, handoff_1.createHandoffToken)(config.handoffSecret, returnOrigin, subject);
119
+ const target = new URL(constants_1.VERCEL_CALLBACK_PATH, `${returnOrigin}/`);
120
+ target.searchParams.set("handoff", handoff);
121
+ target.searchParams.set("return_to", returnPath);
122
+ const response = new Response(null, {
123
+ status: 302,
124
+ headers: {
125
+ Location: target.toString(),
126
+ "Cache-Control": "no-store",
127
+ },
128
+ });
129
+ (0, vercel_oauth_1.clearOAuthCookies)(response, secure);
130
+ return response;
131
+ }
132
+ catch {
133
+ return fail("Vercel sign-in failed");
134
+ }
135
+ }
@@ -4,6 +4,13 @@ import type { DeploymentProtectionConfig, DeploymentProtectionOptions } from "./
4
4
  */
5
5
  export declare function resolveConfig(options?: DeploymentProtectionOptions): DeploymentProtectionConfig;
6
6
  export declare function hasPasswordAuth(config: DeploymentProtectionConfig): boolean;
7
+ /** Direct Vercel OAuth on the protected app (legacy / single-project setup). */
8
+ export declare function hasVercelDirectAuth(config: DeploymentProtectionConfig): boolean;
9
+ /**
10
+ * Central auth-proxy mode: apps only need the proxy URL + shared handoff secret.
11
+ * Vercel client credentials live solely on the proxy.
12
+ */
13
+ export declare function hasVercelProxyAuth(config: DeploymentProtectionConfig): boolean;
7
14
  export declare function hasVercelAuth(config: DeploymentProtectionConfig): boolean;
8
15
  /**
9
16
  * Protection only runs when enabled and at least one auth method is configured.
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,2BAA2B,EAAU,MAAM,SAAS,CAAC;AA0B/F;;GAEG;AACH,wBAAgB,aAAa,CACzB,OAAO,GAAE,2BAAgC,GAC1C,0BAA0B,CA6C5B;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAE3E;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAEzE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAK9E"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,2BAA2B,EAAU,MAAM,SAAS,CAAC;AA0B/F;;GAEG;AACH,wBAAgB,aAAa,CACzB,OAAO,GAAE,2BAAgC,GAC1C,0BAA0B,CA0D5B;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAE3E;AAED,gFAAgF;AAChF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAE/E;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAE9E;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAEzE;AAED;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,0BAA0B,GAAG,OAAO,CAK9E"}
@@ -2,8 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveConfig = resolveConfig;
4
4
  exports.hasPasswordAuth = hasPasswordAuth;
5
+ exports.hasVercelDirectAuth = hasVercelDirectAuth;
6
+ exports.hasVercelProxyAuth = hasVercelProxyAuth;
5
7
  exports.hasVercelAuth = hasVercelAuth;
6
8
  exports.isProtectionActive = isProtectionActive;
9
+ const handoff_1 = require("./handoff");
7
10
  const FALSEY = new Set(["0", "false", "no", "off"]);
8
11
  function read(env, ...keys) {
9
12
  for (const key of keys) {
@@ -35,12 +38,17 @@ function resolveConfig(options = {}) {
35
38
  // Prefer an explicit secret; otherwise derive a stable key from credentials so
36
39
  // projects can stay zero-config beyond username/password.
37
40
  const secret = explicitSecret ?? (username && password ? `dp:${username}:${password}` : null);
41
+ const handoffSecret = read(env, "DEPLOYMENT_PROTECTION_HANDOFF_SECRET") ?? explicitSecret ?? secret;
42
+ const authProxyUrl = read(env, "DEPLOYMENT_PROTECTION_AUTH_PROXY_URL")?.replace(/\/$/, "") ?? null;
38
43
  return {
39
44
  enabled: isEnabled(env),
40
45
  username,
41
46
  password,
42
47
  secret,
48
+ handoffSecret,
43
49
  bypassSecret: read(env, "VERCEL_AUTOMATION_BYPASS_SECRET", "DEPLOYMENT_PROTECTION_BYPASS_SECRET"),
50
+ authProxyUrl,
51
+ allowedReturnOrigins: (0, handoff_1.parseAllowlist)(read(env, "DEPLOYMENT_PROTECTION_ALLOWED_ORIGINS", "DEPLOYMENT_PROTECTION_AUTH_PROXY_ALLOWED_ORIGINS")),
44
52
  vercelClientId: read(env, "DEPLOYMENT_PROTECTION_VERCEL_CLIENT_ID", "NEXT_PUBLIC_VERCEL_APP_CLIENT_ID", "VERCEL_APP_CLIENT_ID"),
45
53
  vercelClientSecret: read(env, "DEPLOYMENT_PROTECTION_VERCEL_CLIENT_SECRET", "VERCEL_APP_CLIENT_SECRET"),
46
54
  sessionTtlSeconds: options.sessionTtlSeconds ?? 60 * 60 * 24 * 14,
@@ -55,9 +63,20 @@ function resolveConfig(options = {}) {
55
63
  function hasPasswordAuth(config) {
56
64
  return Boolean(config.username && config.password && config.secret);
57
65
  }
58
- function hasVercelAuth(config) {
66
+ /** Direct Vercel OAuth on the protected app (legacy / single-project setup). */
67
+ function hasVercelDirectAuth(config) {
59
68
  return Boolean(config.vercelClientId && config.vercelClientSecret && config.secret);
60
69
  }
70
+ /**
71
+ * Central auth-proxy mode: apps only need the proxy URL + shared handoff secret.
72
+ * Vercel client credentials live solely on the proxy.
73
+ */
74
+ function hasVercelProxyAuth(config) {
75
+ return Boolean(config.authProxyUrl && config.handoffSecret && config.secret);
76
+ }
77
+ function hasVercelAuth(config) {
78
+ return hasVercelDirectAuth(config) || hasVercelProxyAuth(config);
79
+ }
61
80
  /**
62
81
  * Protection only runs when enabled and at least one auth method is configured.
63
82
  * Misconfigured+enabled is treated as inactive (fail-open) so local/dev isn't bricked.
@@ -4,6 +4,7 @@ export declare const OAUTH_STATE_COOKIE = "__becklyn_dp_oauth_state";
4
4
  export declare const OAUTH_NONCE_COOKIE = "__becklyn_dp_oauth_nonce";
5
5
  export declare const OAUTH_VERIFIER_COOKIE = "__becklyn_dp_oauth_verifier";
6
6
  export declare const OAUTH_RETURN_COOKIE = "__becklyn_dp_oauth_return";
7
+ export declare const OAUTH_RETURN_ORIGIN_COOKIE = "__becklyn_dp_oauth_return_origin";
7
8
  export declare const INTERNAL_PATH_PREFIX = "/_becklyn/deployment-protection";
8
9
  export declare const VERCEL_AUTHORIZE_PATH = "/_becklyn/deployment-protection/vercel";
9
10
  export declare const VERCEL_CALLBACK_PATH = "/_becklyn/deployment-protection/vercel/callback";
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAC1D,eAAO,MAAM,kBAAkB,wBAAwB,CAAC;AACxD,eAAO,MAAM,kBAAkB,6BAA6B,CAAC;AAC7D,eAAO,MAAM,kBAAkB,6BAA6B,CAAC;AAC7D,eAAO,MAAM,qBAAqB,gCAAgC,CAAC;AACnE,eAAO,MAAM,mBAAmB,8BAA8B,CAAC;AAE/D,eAAO,MAAM,oBAAoB,oCAAoC,CAAC;AACtE,eAAO,MAAM,qBAAqB,2CAAmC,CAAC;AACtE,eAAO,MAAM,oBAAoB,oDAA4C,CAAC;AAE9E,eAAO,MAAM,aAAa,+BAA+B,CAAC;AAC1D,eAAO,MAAM,wBAAwB,+BAA+B,CAAC;AAErE,eAAO,MAAM,2BAA2B,QAAoB,CAAC"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/constants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,mBAAmB,yBAAyB,CAAC;AAC1D,eAAO,MAAM,kBAAkB,wBAAwB,CAAC;AACxD,eAAO,MAAM,kBAAkB,6BAA6B,CAAC;AAC7D,eAAO,MAAM,kBAAkB,6BAA6B,CAAC;AAC7D,eAAO,MAAM,qBAAqB,gCAAgC,CAAC;AACnE,eAAO,MAAM,mBAAmB,8BAA8B,CAAC;AAC/D,eAAO,MAAM,0BAA0B,qCAAqC,CAAC;AAE7E,eAAO,MAAM,oBAAoB,oCAAoC,CAAC;AACtE,eAAO,MAAM,qBAAqB,2CAAmC,CAAC;AACtE,eAAO,MAAM,oBAAoB,oDAA4C,CAAC;AAE9E,eAAO,MAAM,aAAa,+BAA+B,CAAC;AAC1D,eAAO,MAAM,wBAAwB,+BAA+B,CAAC;AAErE,eAAO,MAAM,2BAA2B,QAAoB,CAAC"}
@@ -1,12 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_SESSION_TTL_SECONDS = exports.SET_BYPASS_COOKIE_HEADER = exports.BYPASS_HEADER = exports.VERCEL_CALLBACK_PATH = exports.VERCEL_AUTHORIZE_PATH = exports.INTERNAL_PATH_PREFIX = exports.OAUTH_RETURN_COOKIE = exports.OAUTH_VERIFIER_COOKIE = exports.OAUTH_NONCE_COOKIE = exports.OAUTH_STATE_COOKIE = exports.BYPASS_COOKIE_NAME = exports.SESSION_COOKIE_NAME = void 0;
3
+ exports.DEFAULT_SESSION_TTL_SECONDS = exports.SET_BYPASS_COOKIE_HEADER = exports.BYPASS_HEADER = exports.VERCEL_CALLBACK_PATH = exports.VERCEL_AUTHORIZE_PATH = exports.INTERNAL_PATH_PREFIX = exports.OAUTH_RETURN_ORIGIN_COOKIE = exports.OAUTH_RETURN_COOKIE = exports.OAUTH_VERIFIER_COOKIE = exports.OAUTH_NONCE_COOKIE = exports.OAUTH_STATE_COOKIE = exports.BYPASS_COOKIE_NAME = exports.SESSION_COOKIE_NAME = void 0;
4
4
  exports.SESSION_COOKIE_NAME = "__becklyn_dp_session";
5
5
  exports.BYPASS_COOKIE_NAME = "__becklyn_dp_bypass";
6
6
  exports.OAUTH_STATE_COOKIE = "__becklyn_dp_oauth_state";
7
7
  exports.OAUTH_NONCE_COOKIE = "__becklyn_dp_oauth_nonce";
8
8
  exports.OAUTH_VERIFIER_COOKIE = "__becklyn_dp_oauth_verifier";
9
9
  exports.OAUTH_RETURN_COOKIE = "__becklyn_dp_oauth_return";
10
+ exports.OAUTH_RETURN_ORIGIN_COOKIE = "__becklyn_dp_oauth_return_origin";
10
11
  exports.INTERNAL_PATH_PREFIX = "/_becklyn/deployment-protection";
11
12
  exports.VERCEL_AUTHORIZE_PATH = `${exports.INTERNAL_PATH_PREFIX}/vercel`;
12
13
  exports.VERCEL_CALLBACK_PATH = `${exports.INTERNAL_PATH_PREFIX}/vercel/callback`;
@@ -1 +1 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/handler.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAA8B,2BAA2B,EAAE,MAAM,SAAS,CAAC;AA6OvF;;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":"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"}
@@ -5,6 +5,7 @@ const bypass_1 = require("./bypass");
5
5
  const config_1 = require("./config");
6
6
  const constants_1 = require("./constants");
7
7
  const crypto_1 = require("./crypto");
8
+ const handoff_1 = require("./handoff");
8
9
  const login_page_1 = require("./login-page");
9
10
  const password_1 = require("./password");
10
11
  const safe_return_to_1 = require("./safe-return-to");
@@ -117,6 +118,19 @@ async function handleVercelAuthorize(request, config) {
117
118
  return new Response("Sign in with Vercel is not configured", { status: 500 });
118
119
  }
119
120
  const returnTo = (0, safe_return_to_1.safeReturnTo)(new URL(request.url).searchParams.get("return_to"));
121
+ // Prefer central auth-proxy so apps never register per-host OAuth callbacks.
122
+ if ((0, config_1.hasVercelProxyAuth)(config) && config.authProxyUrl && config.handoffSecret) {
123
+ const startUrl = await (0, handoff_1.buildProxyStartUrl)({
124
+ authProxyUrl: config.authProxyUrl,
125
+ secret: config.handoffSecret,
126
+ returnOrigin: new URL(request.url).origin,
127
+ returnPath: returnTo,
128
+ });
129
+ return redirect(request, startUrl);
130
+ }
131
+ if (!(0, config_1.hasVercelDirectAuth)(config)) {
132
+ return new Response("Sign in with Vercel is not configured", { status: 500 });
133
+ }
120
134
  return (0, vercel_oauth_1.buildVercelAuthorizeRedirect)(request, config, returnTo);
121
135
  }
122
136
  async function handleVercelCallback(request, config) {
@@ -127,9 +141,32 @@ async function handleVercelCallback(request, config) {
127
141
  return new Response("Sign in with Vercel is not configured", { status: 500 });
128
142
  }
129
143
  const url = new URL(request.url);
144
+ const handoff = url.searchParams.get("handoff");
145
+ const secure = isSecureRequest(request);
146
+ // Auth-proxy handoff: short-lived signed token, no Vercel client secret on the app.
147
+ if (handoff) {
148
+ if (!config.handoffSecret) {
149
+ return new Response("Sign in with Vercel is not configured", { status: 500 });
150
+ }
151
+ const returnTo = (0, safe_return_to_1.safeReturnTo)(url.searchParams.get("return_to"));
152
+ const payload = await (0, handoff_1.verifyHandoffToken)(config.handoffSecret, handoff, url.origin);
153
+ if (!payload) {
154
+ return htmlResponse((0, login_page_1.renderLoginPage)(config, {
155
+ error: "Vercel sign-in failed (invalid handoff).",
156
+ returnTo,
157
+ showPassword: (0, config_1.hasPasswordAuth)(config),
158
+ showVercel: true,
159
+ }));
160
+ }
161
+ const response = redirect(request, returnTo);
162
+ return attachSession(response, config, "vercel", payload.subject, secure);
163
+ }
164
+ // Legacy direct OAuth callback on the protected app.
165
+ if (!(0, config_1.hasVercelDirectAuth)(config)) {
166
+ return new Response("Sign in with Vercel is not configured", { status: 500 });
167
+ }
130
168
  const code = url.searchParams.get("code");
131
169
  const state = url.searchParams.get("state");
132
- const secure = isSecureRequest(request);
133
170
  const returnTo = (0, safe_return_to_1.safeReturnTo)((0, vercel_oauth_1.readCookie)(request, constants_1.OAUTH_RETURN_COOKIE));
134
171
  if (!code || !(await (0, vercel_oauth_1.assertOAuthState)(request, state))) {
135
172
  const response = htmlResponse((0, login_page_1.renderLoginPage)(config, {
@@ -0,0 +1,46 @@
1
+ export declare const HANDOFF_TTL_SECONDS = 60;
2
+ export declare const PROXY_START_TTL_SECONDS: number;
3
+ export interface ProxyStartParams {
4
+ returnOrigin: string;
5
+ returnPath: string;
6
+ exp: number;
7
+ nonce: string;
8
+ }
9
+ export interface HandoffPayload {
10
+ exp: number;
11
+ aud: string;
12
+ subject: string;
13
+ method: "vercel";
14
+ }
15
+ /**
16
+ * Canonical payload for HMAC over the proxy start request.
17
+ * Field order is fixed so app and proxy produce the same signature.
18
+ */
19
+ export declare function canonicalProxyStartPayload(params: ProxyStartParams): string;
20
+ export declare function signProxyStart(secret: string, params: ProxyStartParams): Promise<string>;
21
+ export declare function verifyProxyStartSignature(secret: string, params: ProxyStartParams, signature: string | null | undefined): Promise<boolean>;
22
+ export declare function buildProxyStartUrl(options: {
23
+ authProxyUrl: string;
24
+ secret: string;
25
+ returnOrigin: string;
26
+ returnPath: string;
27
+ ttlSeconds?: number;
28
+ }): Promise<string>;
29
+ export declare function createHandoffToken(secret: string, audienceOrigin: string, subject: string, ttlSeconds?: number): Promise<string>;
30
+ export declare function verifyHandoffToken(secret: string, token: string | null | undefined, expectedAudienceOrigin: string): Promise<HandoffPayload | null>;
31
+ /**
32
+ * Validate a post-login return origin for the auth proxy.
33
+ * Only https (and http://localhost / 127.0.0.1) are accepted.
34
+ */
35
+ export declare function normalizeReturnOrigin(value: string | null | undefined): string | null;
36
+ /**
37
+ * Optional allowlist. Entries may be:
38
+ * - full origins (`https://app.example.com`)
39
+ * - hostname suffixes (`.vercel.app`, `example.com`)
40
+ * - `localhost` (any localhost / 127.0.0.1 http(s) origin)
41
+ *
42
+ * Empty allowlist → any origin that passes {@link normalizeReturnOrigin}.
43
+ */
44
+ export declare function isReturnOriginAllowed(origin: string, allowlist: string[]): boolean;
45
+ export declare function parseAllowlist(value: string | null | undefined): string[];
46
+ //# sourceMappingURL=handoff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"handoff.d.ts","sourceRoot":"","sources":["../../src/handoff.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,mBAAmB,KAAK,CAAC;AACtC,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAE9C,MAAM,WAAW,gBAAgB;IAC7B,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,QAAQ,CAAC;CACpB;AAeD;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,CAE3E;AAED,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAE9F;AAED,wBAAsB,yBAAyB,CAC3C,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,gBAAgB,EACxB,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,kBAAkB,CAAC,OAAO,EAAE;IAC9C,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,MAAM,CAAC,CAgBlB;AAWD,wBAAsB,kBAAkB,CACpC,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,EACtB,OAAO,EAAE,MAAM,EACf,UAAU,SAAsB,GACjC,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,wBAAsB,kBAAkB,CACpC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,sBAAsB,EAAE,MAAM,GAC/B,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAwChC;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CA4BrF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,OAAO,CAgDlF;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,EAAE,CASzE"}