@becklyn/deployment-protection 0.0.1
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/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/cjs/bypass.d.ts +5 -0
- package/dist/cjs/bypass.d.ts.map +1 -0
- package/dist/cjs/bypass.js +51 -0
- package/dist/cjs/config.d.ts +13 -0
- package/dist/cjs/config.d.ts.map +1 -0
- package/dist/cjs/config.js +68 -0
- package/dist/cjs/constants.d.ts +15 -0
- package/dist/cjs/constants.d.ts.map +1 -0
- package/dist/cjs/constants.js +19 -0
- package/dist/cjs/crypto.d.ts +7 -0
- package/dist/cjs/crypto.d.ts.map +1 -0
- package/dist/cjs/crypto.js +56 -0
- package/dist/cjs/handler.d.ts +6 -0
- package/dist/cjs/handler.d.ts.map +1 -0
- package/dist/cjs/handler.js +212 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +17 -0
- package/dist/cjs/login-page.d.ts +8 -0
- package/dist/cjs/login-page.d.ts.map +1 -0
- package/dist/cjs/login-page.js +176 -0
- package/dist/cjs/middleware.d.ts +25 -0
- package/dist/cjs/middleware.d.ts.map +1 -0
- package/dist/cjs/middleware.js +63 -0
- package/dist/cjs/password.d.ts +3 -0
- package/dist/cjs/password.d.ts.map +1 -0
- package/dist/cjs/password.js +12 -0
- package/dist/cjs/safe-return-to.d.ts +8 -0
- package/dist/cjs/safe-return-to.d.ts.map +1 -0
- package/dist/cjs/safe-return-to.js +60 -0
- package/dist/cjs/session.d.ts +11 -0
- package/dist/cjs/session.d.ts.map +1 -0
- package/dist/cjs/session.js +64 -0
- package/dist/cjs/types.d.ts +35 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +2 -0
- package/dist/cjs/vercel-oauth.d.ts +30 -0
- package/dist/cjs/vercel-oauth.d.ts.map +1 -0
- package/dist/cjs/vercel-oauth.js +166 -0
- package/dist/es/bypass.d.ts +5 -0
- package/dist/es/bypass.d.ts.map +1 -0
- package/dist/es/bypass.js +45 -0
- package/dist/es/config.d.ts +13 -0
- package/dist/es/config.d.ts.map +1 -0
- package/dist/es/config.js +62 -0
- package/dist/es/constants.d.ts +15 -0
- package/dist/es/constants.d.ts.map +1 -0
- package/dist/es/constants.js +16 -0
- package/dist/es/crypto.d.ts +7 -0
- package/dist/es/crypto.d.ts.map +1 -0
- package/dist/es/crypto.js +48 -0
- package/dist/es/handler.d.ts +6 -0
- package/dist/es/handler.d.ts.map +1 -0
- package/dist/es/handler.js +209 -0
- package/dist/es/index.d.ts +6 -0
- package/dist/es/index.d.ts.map +1 -0
- package/dist/es/index.js +4 -0
- package/dist/es/login-page.d.ts +8 -0
- package/dist/es/login-page.d.ts.map +1 -0
- package/dist/es/login-page.js +173 -0
- package/dist/es/middleware.d.ts +25 -0
- package/dist/es/middleware.d.ts.map +1 -0
- package/dist/es/middleware.js +59 -0
- package/dist/es/password.d.ts +3 -0
- package/dist/es/password.d.ts.map +1 -0
- package/dist/es/password.js +9 -0
- package/dist/es/safe-return-to.d.ts +8 -0
- package/dist/es/safe-return-to.d.ts.map +1 -0
- package/dist/es/safe-return-to.js +57 -0
- package/dist/es/session.d.ts +11 -0
- package/dist/es/session.d.ts.map +1 -0
- package/dist/es/session.js +59 -0
- package/dist/es/types.d.ts +35 -0
- package/dist/es/types.d.ts.map +1 -0
- package/dist/es/types.js +1 -0
- package/dist/es/vercel-oauth.d.ts +30 -0
- package/dist/es/vercel-oauth.d.ts.map +1 -0
- package/dist/es/vercel-oauth.js +156 -0
- package/package.json +46 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { VERCEL_AUTHORIZE_PATH } from "./constants";
|
|
2
|
+
function escapeHtml(value) {
|
|
3
|
+
return value
|
|
4
|
+
.replace(/&/g, "&")
|
|
5
|
+
.replace(/</g, "<")
|
|
6
|
+
.replace(/>/g, ">")
|
|
7
|
+
.replace(/"/g, """)
|
|
8
|
+
.replace(/'/g, "'");
|
|
9
|
+
}
|
|
10
|
+
export function renderLoginPage(config, options = {}) {
|
|
11
|
+
const returnTo = options.returnTo ?? "/";
|
|
12
|
+
const showPassword = options.showPassword ?? true;
|
|
13
|
+
const showVercel = options.showVercel ?? false;
|
|
14
|
+
const error = options.error ? `<p class="error">${escapeHtml(options.error)}</p>` : "";
|
|
15
|
+
const vercelButton = showVercel
|
|
16
|
+
? `<a class="secondary" href="${VERCEL_AUTHORIZE_PATH}?return_to=${encodeURIComponent(returnTo)}">Sign in with Vercel</a>`
|
|
17
|
+
: "";
|
|
18
|
+
const passwordForm = showPassword
|
|
19
|
+
? `
|
|
20
|
+
<form method="post" action="${escapeHtml(returnTo)}">
|
|
21
|
+
<input type="hidden" name="__becklyn_dp" value="1" />
|
|
22
|
+
<input type="hidden" name="return_to" value="${escapeHtml(returnTo)}" />
|
|
23
|
+
<label>
|
|
24
|
+
Username
|
|
25
|
+
<input name="username" type="text" autocomplete="username" required autofocus />
|
|
26
|
+
</label>
|
|
27
|
+
<label>
|
|
28
|
+
Password
|
|
29
|
+
<input name="password" type="password" autocomplete="current-password" required />
|
|
30
|
+
</label>
|
|
31
|
+
<button type="submit">Continue</button>
|
|
32
|
+
</form>`
|
|
33
|
+
: "";
|
|
34
|
+
const divider = showPassword && showVercel ? `<div class="divider"><span>or</span></div>` : "";
|
|
35
|
+
return `<!doctype html>
|
|
36
|
+
<html lang="en">
|
|
37
|
+
<head>
|
|
38
|
+
<meta charset="utf-8" />
|
|
39
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
40
|
+
<title>${escapeHtml(config.formTitle)}</title>
|
|
41
|
+
<style>
|
|
42
|
+
:root {
|
|
43
|
+
color-scheme: light dark;
|
|
44
|
+
--bg: #0b0f19;
|
|
45
|
+
--card: #121826;
|
|
46
|
+
--text: #e8eefc;
|
|
47
|
+
--muted: #9aa8c7;
|
|
48
|
+
--accent: #3b82f6;
|
|
49
|
+
--border: #243044;
|
|
50
|
+
--error: #f87171;
|
|
51
|
+
}
|
|
52
|
+
@media (prefers-color-scheme: light) {
|
|
53
|
+
:root {
|
|
54
|
+
--bg: #f4f7fb;
|
|
55
|
+
--card: #ffffff;
|
|
56
|
+
--text: #0f172a;
|
|
57
|
+
--muted: #64748b;
|
|
58
|
+
--border: #e2e8f0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
* { box-sizing: border-box; }
|
|
62
|
+
body {
|
|
63
|
+
margin: 0;
|
|
64
|
+
min-height: 100vh;
|
|
65
|
+
display: grid;
|
|
66
|
+
place-items: center;
|
|
67
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
|
68
|
+
background:
|
|
69
|
+
radial-gradient(1200px 600px at 10% -10%, rgba(59,130,246,.25), transparent 60%),
|
|
70
|
+
radial-gradient(900px 500px at 100% 0%, rgba(14,165,233,.18), transparent 55%),
|
|
71
|
+
var(--bg);
|
|
72
|
+
color: var(--text);
|
|
73
|
+
padding: 24px;
|
|
74
|
+
}
|
|
75
|
+
.card {
|
|
76
|
+
width: min(100%, 420px);
|
|
77
|
+
background: color-mix(in srgb, var(--card) 92%, transparent);
|
|
78
|
+
border: 1px solid var(--border);
|
|
79
|
+
border-radius: 16px;
|
|
80
|
+
padding: 28px;
|
|
81
|
+
box-shadow: 0 20px 50px rgba(0,0,0,.25);
|
|
82
|
+
backdrop-filter: blur(8px);
|
|
83
|
+
}
|
|
84
|
+
h1 {
|
|
85
|
+
margin: 0 0 8px;
|
|
86
|
+
font-size: 1.35rem;
|
|
87
|
+
letter-spacing: -0.02em;
|
|
88
|
+
}
|
|
89
|
+
p {
|
|
90
|
+
margin: 0 0 20px;
|
|
91
|
+
color: var(--muted);
|
|
92
|
+
line-height: 1.5;
|
|
93
|
+
font-size: .95rem;
|
|
94
|
+
}
|
|
95
|
+
form { display: grid; gap: 14px; }
|
|
96
|
+
label {
|
|
97
|
+
display: grid;
|
|
98
|
+
gap: 6px;
|
|
99
|
+
font-size: .85rem;
|
|
100
|
+
color: var(--muted);
|
|
101
|
+
}
|
|
102
|
+
input {
|
|
103
|
+
width: 100%;
|
|
104
|
+
border: 1px solid var(--border);
|
|
105
|
+
border-radius: 10px;
|
|
106
|
+
padding: 12px 14px;
|
|
107
|
+
font: inherit;
|
|
108
|
+
color: var(--text);
|
|
109
|
+
background: transparent;
|
|
110
|
+
}
|
|
111
|
+
input:focus {
|
|
112
|
+
outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
|
|
113
|
+
border-color: var(--accent);
|
|
114
|
+
}
|
|
115
|
+
button, .secondary {
|
|
116
|
+
appearance: none;
|
|
117
|
+
border: 0;
|
|
118
|
+
border-radius: 10px;
|
|
119
|
+
padding: 12px 14px;
|
|
120
|
+
font: inherit;
|
|
121
|
+
font-weight: 600;
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
text-align: center;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
}
|
|
126
|
+
button {
|
|
127
|
+
background: var(--accent);
|
|
128
|
+
color: white;
|
|
129
|
+
}
|
|
130
|
+
.secondary {
|
|
131
|
+
display: block;
|
|
132
|
+
background: transparent;
|
|
133
|
+
color: var(--text);
|
|
134
|
+
border: 1px solid var(--border);
|
|
135
|
+
}
|
|
136
|
+
.divider {
|
|
137
|
+
display: grid;
|
|
138
|
+
grid-template-columns: 1fr auto 1fr;
|
|
139
|
+
gap: 12px;
|
|
140
|
+
align-items: center;
|
|
141
|
+
margin: 18px 0;
|
|
142
|
+
color: var(--muted);
|
|
143
|
+
font-size: .8rem;
|
|
144
|
+
text-transform: uppercase;
|
|
145
|
+
letter-spacing: .08em;
|
|
146
|
+
}
|
|
147
|
+
.divider::before, .divider::after {
|
|
148
|
+
content: "";
|
|
149
|
+
height: 1px;
|
|
150
|
+
background: var(--border);
|
|
151
|
+
}
|
|
152
|
+
.error {
|
|
153
|
+
color: var(--error);
|
|
154
|
+
background: color-mix(in srgb, var(--error) 12%, transparent);
|
|
155
|
+
border: 1px solid color-mix(in srgb, var(--error) 35%, transparent);
|
|
156
|
+
border-radius: 10px;
|
|
157
|
+
padding: 10px 12px;
|
|
158
|
+
margin: 0 0 16px;
|
|
159
|
+
}
|
|
160
|
+
</style>
|
|
161
|
+
</head>
|
|
162
|
+
<body>
|
|
163
|
+
<main class="card">
|
|
164
|
+
<h1>${escapeHtml(config.formTitle)}</h1>
|
|
165
|
+
<p>${escapeHtml(config.formDescription)}</p>
|
|
166
|
+
${error}
|
|
167
|
+
${passwordForm}
|
|
168
|
+
${divider}
|
|
169
|
+
${vercelButton}
|
|
170
|
+
</main>
|
|
171
|
+
</body>
|
|
172
|
+
</html>`;
|
|
173
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { deploymentProtectionMatcher } from "./constants";
|
|
3
|
+
import type { DeploymentProtectionOptions } from "./types";
|
|
4
|
+
type NextMiddleware = (request: NextRequest, event?: unknown) => Response | NextResponse | Promise<Response | NextResponse | undefined | void> | undefined | void;
|
|
5
|
+
export type WithDeploymentProtectionArgs = [] | [DeploymentProtectionOptions] | [NextMiddleware] | [NextMiddleware, DeploymentProtectionOptions];
|
|
6
|
+
/**
|
|
7
|
+
* Next.js middleware / proxy wrapper.
|
|
8
|
+
*
|
|
9
|
+
* @example middleware.ts (Next ≤15)
|
|
10
|
+
* ```ts
|
|
11
|
+
* import {withDeploymentProtection, deploymentProtectionMatcher} from "@becklyn/deployment-protection";
|
|
12
|
+
* export default withDeploymentProtection();
|
|
13
|
+
* export const config = { matcher: deploymentProtectionMatcher };
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* @example proxy.ts (Next 16+)
|
|
17
|
+
* ```ts
|
|
18
|
+
* import {withDeploymentProtection, deploymentProtectionMatcher} from "@becklyn/deployment-protection";
|
|
19
|
+
* export const proxy = withDeploymentProtection();
|
|
20
|
+
* export const config = { matcher: deploymentProtectionMatcher };
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function withDeploymentProtection(...args: WithDeploymentProtectionArgs): (request: NextRequest, event?: unknown) => Promise<Response | NextResponse>;
|
|
24
|
+
export { deploymentProtectionMatcher };
|
|
25
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +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;AAC7D,OAAO,EAAE,2BAA2B,EAAE,MAAM,aAAa,CAAC;AAE1D,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;AAWpD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,IAAI,EAAE,4BAA4B,IAgBtE,SAAS,WAAW,EACpB,QAAQ,OAAO,KAChB,OAAO,CAAC,QAAQ,GAAG,YAAY,CAAC,CAmBtC;AAED,OAAO,EAAE,2BAA2B,EAAE,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { deploymentProtectionMatcher } from "./constants";
|
|
3
|
+
import { handleDeploymentProtection } from "./handler";
|
|
4
|
+
function isOptions(value) {
|
|
5
|
+
return (typeof value === "object" &&
|
|
6
|
+
value !== null &&
|
|
7
|
+
!Array.isArray(value) &&
|
|
8
|
+
typeof value !== "function");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Next.js middleware / proxy wrapper.
|
|
12
|
+
*
|
|
13
|
+
* @example middleware.ts (Next ≤15)
|
|
14
|
+
* ```ts
|
|
15
|
+
* import {withDeploymentProtection, deploymentProtectionMatcher} from "@becklyn/deployment-protection";
|
|
16
|
+
* export default withDeploymentProtection();
|
|
17
|
+
* export const config = { matcher: deploymentProtectionMatcher };
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* @example proxy.ts (Next 16+)
|
|
21
|
+
* ```ts
|
|
22
|
+
* import {withDeploymentProtection, deploymentProtectionMatcher} from "@becklyn/deployment-protection";
|
|
23
|
+
* export const proxy = withDeploymentProtection();
|
|
24
|
+
* export const config = { matcher: deploymentProtectionMatcher };
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function withDeploymentProtection(...args) {
|
|
28
|
+
let next;
|
|
29
|
+
let options = {};
|
|
30
|
+
if (args.length === 1) {
|
|
31
|
+
if (typeof args[0] === "function") {
|
|
32
|
+
next = args[0];
|
|
33
|
+
}
|
|
34
|
+
else if (isOptions(args[0])) {
|
|
35
|
+
options = args[0];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else if (args.length === 2) {
|
|
39
|
+
next = args[0];
|
|
40
|
+
options = args[1] ?? {};
|
|
41
|
+
}
|
|
42
|
+
return async function deploymentProtectionMiddleware(request, event) {
|
|
43
|
+
const protectionResponse = await handleDeploymentProtection(request, options);
|
|
44
|
+
if (protectionResponse) {
|
|
45
|
+
// Preserve NextResponse subclass when possible
|
|
46
|
+
return new NextResponse(protectionResponse.body, {
|
|
47
|
+
status: protectionResponse.status,
|
|
48
|
+
statusText: protectionResponse.statusText,
|
|
49
|
+
headers: protectionResponse.headers,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
if (next) {
|
|
53
|
+
const result = await next(request, event);
|
|
54
|
+
return result ?? NextResponse.next();
|
|
55
|
+
}
|
|
56
|
+
return NextResponse.next();
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export { deploymentProtectionMatcher };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"password.d.ts","sourceRoot":"","sources":["../../src/password.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,SAAS,CAAC;AAE1D,wBAAsB,2BAA2B,CAC7C,MAAM,EAAE,0BAA0B,EAClC,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GACjB,OAAO,CAAC,OAAO,CAAC,CAQlB"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { timingSafeEqualString } from "./crypto";
|
|
2
|
+
export async function validatePasswordCredentials(config, username, password) {
|
|
3
|
+
if (!config.username || !config.password) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const userOk = await timingSafeEqualString(username, config.username);
|
|
7
|
+
const passOk = await timingSafeEqualString(password, config.password);
|
|
8
|
+
return userOk && passOk;
|
|
9
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitize a post-login redirect target so it can never leave the current origin.
|
|
3
|
+
*
|
|
4
|
+
* Browsers treat `\` like `/` in URL resolution, so values such as `/\evil.com`
|
|
5
|
+
* must be rejected in addition to classic `//evil.com` protocol-relative URLs.
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeReturnTo(value: string | null | undefined, fallback?: string): string;
|
|
8
|
+
//# sourceMappingURL=safe-return-to.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"safe-return-to.d.ts","sourceRoot":"","sources":["../../src/safe-return-to.ts"],"names":[],"mappings":"AAaA;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAAE,QAAQ,SAAM,GAAG,MAAM,CAiDrF"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function hasUnsafeCharacters(value) {
|
|
2
|
+
for (let i = 0; i < value.length; i++) {
|
|
3
|
+
const code = value.charCodeAt(i);
|
|
4
|
+
// Control chars, DEL, and any Unicode whitespace (incl. regular space).
|
|
5
|
+
if (code <= 0x1f || code === 0x7f || /\s/.test(value[i])) {
|
|
6
|
+
return true;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize a post-login redirect target so it can never leave the current origin.
|
|
13
|
+
*
|
|
14
|
+
* Browsers treat `\` like `/` in URL resolution, so values such as `/\evil.com`
|
|
15
|
+
* must be rejected in addition to classic `//evil.com` protocol-relative URLs.
|
|
16
|
+
*/
|
|
17
|
+
export function safeReturnTo(value, fallback = "/") {
|
|
18
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 2048) {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
// Fast reject before any decoding / URL parsing.
|
|
22
|
+
if (!value.startsWith("/") ||
|
|
23
|
+
value.startsWith("//") ||
|
|
24
|
+
value.includes("\\") ||
|
|
25
|
+
hasUnsafeCharacters(value) ||
|
|
26
|
+
/%5c/i.test(value) || // encoded backslash
|
|
27
|
+
/%00/i.test(value)) {
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
let decoded;
|
|
31
|
+
try {
|
|
32
|
+
// Decode once to catch encoded protocol-relative / backslash payloads.
|
|
33
|
+
decoded = decodeURIComponent(value);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
if (!decoded.startsWith("/") ||
|
|
39
|
+
decoded.startsWith("//") ||
|
|
40
|
+
decoded.includes("\\") ||
|
|
41
|
+
hasUnsafeCharacters(decoded)) {
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const base = "https://safe.invalid";
|
|
46
|
+
// URL parser normalizes `\` → `/`; reject if that escapes our origin.
|
|
47
|
+
const url = new URL(decoded, base);
|
|
48
|
+
if (url.origin !== base || url.username || url.password) {
|
|
49
|
+
return fallback;
|
|
50
|
+
}
|
|
51
|
+
const sanitized = `${url.pathname}${url.search}${url.hash}`;
|
|
52
|
+
return sanitized.startsWith("/") && !sanitized.startsWith("//") ? sanitized : fallback;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AuthMethod, SessionPayload } from "./types";
|
|
2
|
+
export declare function createSessionToken(secret: string, method: AuthMethod, subject: string, ttlSeconds: number): Promise<string>;
|
|
3
|
+
export declare function verifySessionToken(secret: string, token: string | undefined | null): Promise<SessionPayload | null>;
|
|
4
|
+
export declare function sessionCookieOptions(maxAge: number, secure: boolean): {
|
|
5
|
+
httpOnly: boolean;
|
|
6
|
+
sameSite: "lax";
|
|
7
|
+
secure: boolean;
|
|
8
|
+
path: string;
|
|
9
|
+
maxAge: number;
|
|
10
|
+
};
|
|
11
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/session.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAyB1D,wBAAsB,kBAAkB,CACpC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,UAAU,EAClB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACnB,OAAO,CAAC,MAAM,CAAC,CASjB;AAED,wBAAsB,kBAAkB,CACpC,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GACjC,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CA4BhC;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO;;;;;;EAQnE"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { base64UrlToBytes, bytesToBase64Url, hmacSign, timingSafeEqualString } from "./crypto";
|
|
2
|
+
function encodePayload(payload) {
|
|
3
|
+
return bytesToBase64Url(new TextEncoder().encode(JSON.stringify(payload)));
|
|
4
|
+
}
|
|
5
|
+
function decodePayload(encoded) {
|
|
6
|
+
try {
|
|
7
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
8
|
+
const parsed = JSON.parse(json);
|
|
9
|
+
if (typeof parsed.exp !== "number" ||
|
|
10
|
+
typeof parsed.method !== "string" ||
|
|
11
|
+
typeof parsed.subject !== "string") {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
return parsed;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function createSessionToken(secret, method, subject, ttlSeconds) {
|
|
21
|
+
const payload = {
|
|
22
|
+
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
23
|
+
method,
|
|
24
|
+
subject,
|
|
25
|
+
};
|
|
26
|
+
const body = encodePayload(payload);
|
|
27
|
+
const signature = await hmacSign(secret, body);
|
|
28
|
+
return `${body}.${signature}`;
|
|
29
|
+
}
|
|
30
|
+
export async function verifySessionToken(secret, token) {
|
|
31
|
+
if (!token) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const [body, signature] = token.split(".");
|
|
35
|
+
if (!body || !signature) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const expected = await hmacSign(secret, body);
|
|
39
|
+
if (!(await timingSafeEqualString(signature, expected))) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const payload = decodePayload(body);
|
|
43
|
+
if (!payload) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (payload.exp < Math.floor(Date.now() / 1000)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return payload;
|
|
50
|
+
}
|
|
51
|
+
export function sessionCookieOptions(maxAge, secure) {
|
|
52
|
+
return {
|
|
53
|
+
httpOnly: true,
|
|
54
|
+
sameSite: "lax",
|
|
55
|
+
secure,
|
|
56
|
+
path: "/",
|
|
57
|
+
maxAge,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type AuthMethod = "password" | "vercel" | "bypass";
|
|
2
|
+
export interface DeploymentProtectionConfig {
|
|
3
|
+
enabled: boolean;
|
|
4
|
+
username: string | null;
|
|
5
|
+
password: string | null;
|
|
6
|
+
secret: string | null;
|
|
7
|
+
bypassSecret: string | null;
|
|
8
|
+
vercelClientId: string | null;
|
|
9
|
+
vercelClientSecret: string | null;
|
|
10
|
+
sessionTtlSeconds: number;
|
|
11
|
+
formTitle: string;
|
|
12
|
+
formDescription: string;
|
|
13
|
+
}
|
|
14
|
+
export interface FormOptions {
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface DeploymentProtectionOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Optional env override (useful for tests).
|
|
21
|
+
* Merged over `process.env`.
|
|
22
|
+
*/
|
|
23
|
+
env?: Record<string, string | undefined>;
|
|
24
|
+
/** Customize the built-in login form copy. */
|
|
25
|
+
form?: FormOptions;
|
|
26
|
+
/** Session lifetime in seconds (default 14 days). */
|
|
27
|
+
sessionTtlSeconds?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface SessionPayload {
|
|
30
|
+
exp: number;
|
|
31
|
+
method: AuthMethod;
|
|
32
|
+
subject: string;
|
|
33
|
+
}
|
|
34
|
+
export type EnvMap = Record<string, string | undefined>;
|
|
35
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE1D,MAAM,WAAW,0BAA0B;IACvC,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,WAAW;IACxB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,2BAA2B;IACxC;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzC,8CAA8C;IAC9C,IAAI,CAAC,EAAE,WAAW,CAAC;IACnB,qDAAqD;IACrD,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,UAAU,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC"}
|
package/dist/es/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DeploymentProtectionConfig } from "./types";
|
|
2
|
+
export interface VercelTokenResponse {
|
|
3
|
+
access_token: string;
|
|
4
|
+
token_type: string;
|
|
5
|
+
id_token?: string;
|
|
6
|
+
expires_in: number;
|
|
7
|
+
scope?: string;
|
|
8
|
+
refresh_token?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface VercelUserInfo {
|
|
11
|
+
sub?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
preferred_username?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function buildVercelAuthorizeRedirect(request: Request, config: DeploymentProtectionConfig, returnTo: string): Promise<Response>;
|
|
17
|
+
export declare function exchangeVercelCode(request: Request, config: DeploymentProtectionConfig, code: string, codeVerifier: string): Promise<VercelTokenResponse>;
|
|
18
|
+
export declare function fetchVercelUserInfo(accessToken: string): Promise<VercelUserInfo>;
|
|
19
|
+
export declare function readCookie(request: Request, name: string): string | null;
|
|
20
|
+
export declare function assertOAuthState(request: Request, state: string | null): Promise<boolean>;
|
|
21
|
+
export declare function decodeIdTokenNonce(idToken: string | undefined): string | null;
|
|
22
|
+
export declare function clearOAuthCookies(response: Response, secure: boolean): void;
|
|
23
|
+
export declare function appendSetCookie(response: Response, name: string, value: string, options: {
|
|
24
|
+
httpOnly?: boolean;
|
|
25
|
+
secure?: boolean;
|
|
26
|
+
sameSite?: "lax" | "strict" | "none";
|
|
27
|
+
path?: string;
|
|
28
|
+
maxAge?: number;
|
|
29
|
+
}): void;
|
|
30
|
+
//# sourceMappingURL=vercel-oauth.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vercel-oauth.d.ts","sourceRoot":"","sources":["../../src/vercel-oauth.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,SAAS,CAAC;AAE1D,MAAM,WAAW,mBAAmB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,aAAa,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,cAAc;IAC3B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC/B;AAYD,wBAAsB,4BAA4B,CAC9C,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,0BAA0B,EAClC,QAAQ,EAAE,MAAM,GACjB,OAAO,CAAC,QAAQ,CAAC,CAsCnB;AAED,wBAAsB,kBAAkB,CACpC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,0BAA0B,EAClC,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,GACrB,OAAO,CAAC,mBAAmB,CAAC,CA6B9B;AAED,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAYtF;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAgBxE;AAED,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAO/F;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAqB7E;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAiB3E;AAED,wBAAgB,eAAe,CAC3B,QAAQ,EAAE,QAAQ,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,EACb,OAAO,EAAE;IACL,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IACrC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,GACF,IAAI,CAwBN"}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { OAUTH_NONCE_COOKIE, OAUTH_RETURN_COOKIE, OAUTH_STATE_COOKIE, OAUTH_VERIFIER_COOKIE, VERCEL_CALLBACK_PATH, } from "./constants";
|
|
2
|
+
import { randomToken, sha256Base64Url, timingSafeEqualString } from "./crypto";
|
|
3
|
+
function cookieOptions(secure) {
|
|
4
|
+
return {
|
|
5
|
+
httpOnly: true,
|
|
6
|
+
sameSite: "lax",
|
|
7
|
+
secure,
|
|
8
|
+
path: "/",
|
|
9
|
+
maxAge: 10 * 60,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export async function buildVercelAuthorizeRedirect(request, config, returnTo) {
|
|
13
|
+
if (!config.vercelClientId) {
|
|
14
|
+
return new Response("Vercel OAuth is not configured", { status: 500 });
|
|
15
|
+
}
|
|
16
|
+
const state = randomToken(32);
|
|
17
|
+
const nonce = randomToken(32);
|
|
18
|
+
const codeVerifier = randomToken(48);
|
|
19
|
+
const codeChallenge = await sha256Base64Url(codeVerifier);
|
|
20
|
+
const origin = new URL(request.url).origin;
|
|
21
|
+
const redirectUri = `${origin}${VERCEL_CALLBACK_PATH}`;
|
|
22
|
+
const secure = origin.startsWith("https://");
|
|
23
|
+
const params = new URLSearchParams({
|
|
24
|
+
client_id: config.vercelClientId,
|
|
25
|
+
redirect_uri: redirectUri,
|
|
26
|
+
state,
|
|
27
|
+
nonce,
|
|
28
|
+
code_challenge: codeChallenge,
|
|
29
|
+
code_challenge_method: "S256",
|
|
30
|
+
response_type: "code",
|
|
31
|
+
scope: "openid email profile",
|
|
32
|
+
});
|
|
33
|
+
const response = new Response(null, {
|
|
34
|
+
status: 302,
|
|
35
|
+
headers: {
|
|
36
|
+
Location: `https://vercel.com/oauth/authorize?${params.toString()}`,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const opts = cookieOptions(secure);
|
|
40
|
+
appendSetCookie(response, OAUTH_STATE_COOKIE, state, opts);
|
|
41
|
+
appendSetCookie(response, OAUTH_NONCE_COOKIE, nonce, opts);
|
|
42
|
+
appendSetCookie(response, OAUTH_VERIFIER_COOKIE, codeVerifier, opts);
|
|
43
|
+
appendSetCookie(response, OAUTH_RETURN_COOKIE, returnTo || "/", opts);
|
|
44
|
+
return response;
|
|
45
|
+
}
|
|
46
|
+
export async function exchangeVercelCode(request, config, code, codeVerifier) {
|
|
47
|
+
if (!config.vercelClientId || !config.vercelClientSecret) {
|
|
48
|
+
throw new Error("Vercel OAuth is not configured");
|
|
49
|
+
}
|
|
50
|
+
const origin = new URL(request.url).origin;
|
|
51
|
+
const body = new URLSearchParams({
|
|
52
|
+
grant_type: "authorization_code",
|
|
53
|
+
client_id: config.vercelClientId,
|
|
54
|
+
client_secret: config.vercelClientSecret,
|
|
55
|
+
code,
|
|
56
|
+
code_verifier: codeVerifier,
|
|
57
|
+
redirect_uri: `${origin}${VERCEL_CALLBACK_PATH}`,
|
|
58
|
+
});
|
|
59
|
+
const response = await fetch("https://api.vercel.com/login/oauth/token", {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
63
|
+
},
|
|
64
|
+
body,
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const errorText = await response.text();
|
|
68
|
+
throw new Error(`Token exchange failed: ${errorText}`);
|
|
69
|
+
}
|
|
70
|
+
return (await response.json());
|
|
71
|
+
}
|
|
72
|
+
export async function fetchVercelUserInfo(accessToken) {
|
|
73
|
+
const response = await fetch("https://api.vercel.com/login/oauth/userinfo", {
|
|
74
|
+
headers: {
|
|
75
|
+
Authorization: `Bearer ${accessToken}`,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
throw new Error("Failed to load Vercel user info");
|
|
80
|
+
}
|
|
81
|
+
return (await response.json());
|
|
82
|
+
}
|
|
83
|
+
export function readCookie(request, name) {
|
|
84
|
+
const cookieHeader = request.headers.get("cookie");
|
|
85
|
+
if (!cookieHeader) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
for (const part of cookieHeader.split(";")) {
|
|
89
|
+
const [rawName, ...rest] = part.trim().split("=");
|
|
90
|
+
if (rawName === name) {
|
|
91
|
+
return decodeURIComponent(rest.join("="));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
export async function assertOAuthState(request, state) {
|
|
97
|
+
const stored = readCookie(request, OAUTH_STATE_COOKIE);
|
|
98
|
+
if (!state || !stored) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
return timingSafeEqualString(state, stored);
|
|
102
|
+
}
|
|
103
|
+
export function decodeIdTokenNonce(idToken) {
|
|
104
|
+
if (!idToken) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const parts = idToken.split(".");
|
|
108
|
+
const payload = parts[1];
|
|
109
|
+
if (!payload) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const padded = payload.replace(/-/g, "+").replace(/_/g, "/");
|
|
114
|
+
const padLength = (4 - (padded.length % 4)) % 4;
|
|
115
|
+
const json = atob(padded + "=".repeat(padLength));
|
|
116
|
+
const data = JSON.parse(json);
|
|
117
|
+
return data.nonce ?? null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function clearOAuthCookies(response, secure) {
|
|
124
|
+
const expired = {
|
|
125
|
+
httpOnly: true,
|
|
126
|
+
sameSite: "lax",
|
|
127
|
+
secure,
|
|
128
|
+
path: "/",
|
|
129
|
+
maxAge: 0,
|
|
130
|
+
};
|
|
131
|
+
for (const name of [
|
|
132
|
+
OAUTH_STATE_COOKIE,
|
|
133
|
+
OAUTH_NONCE_COOKIE,
|
|
134
|
+
OAUTH_VERIFIER_COOKIE,
|
|
135
|
+
OAUTH_RETURN_COOKIE,
|
|
136
|
+
]) {
|
|
137
|
+
appendSetCookie(response, name, "", expired);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function appendSetCookie(response, name, value, options) {
|
|
141
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
142
|
+
if (options.maxAge !== undefined) {
|
|
143
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
144
|
+
}
|
|
145
|
+
parts.push(`Path=${options.path ?? "/"}`);
|
|
146
|
+
if (options.httpOnly) {
|
|
147
|
+
parts.push("HttpOnly");
|
|
148
|
+
}
|
|
149
|
+
if (options.secure) {
|
|
150
|
+
parts.push("Secure");
|
|
151
|
+
}
|
|
152
|
+
if (options.sameSite) {
|
|
153
|
+
parts.push(`SameSite=${options.sameSite === "none" ? "None" : options.sameSite[0].toUpperCase()}${options.sameSite.slice(1)}`);
|
|
154
|
+
}
|
|
155
|
+
response.headers.append("Set-Cookie", parts.join("; "));
|
|
156
|
+
}
|