@coralreefventures/site-tools 0.1.1 → 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.
- package/README.md +56 -5
- package/dist/bin/reef-door-bundle.d.ts +3 -0
- package/dist/bin/reef-door-bundle.d.ts.map +1 -0
- package/dist/bin/reef-door-bundle.js +56 -0
- package/dist/bin/reef-door-bundle.js.map +1 -0
- package/dist/door/config.d.ts +44 -0
- package/dist/door/config.d.ts.map +1 -0
- package/dist/door/config.js +89 -0
- package/dist/door/config.js.map +1 -0
- package/dist/door/next.d.ts +12 -0
- package/dist/door/next.d.ts.map +1 -0
- package/dist/door/next.js +36 -0
- package/dist/door/next.js.map +1 -0
- package/dist/door/package.d.ts +66 -0
- package/dist/door/package.d.ts.map +1 -0
- package/dist/door/package.js +150 -0
- package/dist/door/package.js.map +1 -0
- package/dist/door/server.d.ts +48 -0
- package/dist/door/server.d.ts.map +1 -0
- package/dist/door/server.js +272 -0
- package/dist/door/server.js.map +1 -0
- package/dist/door/verify.d.ts +52 -0
- package/dist/door/verify.d.ts.map +1 -0
- package/dist/door/verify.js +133 -0
- package/dist/door/verify.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/serve.d.ts +26 -1
- package/dist/serve.d.ts.map +1 -1
- package/dist/serve.js +84 -39
- package/dist/serve.js.map +1 -1
- package/package.json +5 -3
- package/src/bin/reef-door-bundle.ts +60 -0
- package/src/door/config.ts +112 -0
- package/src/door/next.ts +35 -0
- package/src/door/package.ts +184 -0
- package/src/door/server.ts +308 -0
- package/src/door/verify.ts +152 -0
- package/src/index.ts +45 -1
- package/src/serve.ts +101 -29
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type RequestListener, type Server, type ServerResponse } from "node:http";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import { sendFile, staticTarget } from "../serve.ts";
|
|
6
|
+
import { type GateConfig, readGateConfig } from "./config.ts";
|
|
7
|
+
import { canonicalNext, unsafe } from "./next.ts";
|
|
8
|
+
import { verifyTicket } from "./verify.ts";
|
|
9
|
+
|
|
10
|
+
// The gate is the one thing in a locked site's compute bundle, and the bundle carries no node_modules, so this file
|
|
11
|
+
// and everything it imports use Node's own modules only (a test holds it).
|
|
12
|
+
|
|
13
|
+
/** The session: the door's JWS itself, for an hour at most. */
|
|
14
|
+
export const sessionCookie = "__Host-crv_door";
|
|
15
|
+
/** The nonce the gate sets before it sends a visitor to the door, which the ticket must name as `st`. */
|
|
16
|
+
export const stateCookie = "__Host-crv_door_state";
|
|
17
|
+
export const stateLifetimeSeconds = 600;
|
|
18
|
+
|
|
19
|
+
const maxBodyBytes = 16 * 1024;
|
|
20
|
+
const encodedUnsafe = /%(5c|[01][0-9a-f]|7f)/i;
|
|
21
|
+
|
|
22
|
+
/** Every response, whatever its status: none of a locked site may be framed, sniffed or indexed. */
|
|
23
|
+
const securityHeaders = {
|
|
24
|
+
"Strict-Transport-Security": "max-age=63072000",
|
|
25
|
+
"X-Content-Type-Options": "nosniff",
|
|
26
|
+
"X-Frame-Options": "DENY",
|
|
27
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
28
|
+
"X-Robots-Tag": "noindex, nofollow",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cookie = (name: string, value: string, maxAge: number, sameSite: "Lax" | "None") =>
|
|
32
|
+
`${name}=${value}; Max-Age=${maxAge}; Path=/; Secure; HttpOnly; SameSite=${sameSite}`;
|
|
33
|
+
const clearSession = cookie(sessionCookie, "", 0, "Lax");
|
|
34
|
+
const clearState = cookie(stateCookie, "", 0, "None");
|
|
35
|
+
|
|
36
|
+
/** One value of a cookie, or undefined when it is absent or sent twice (which a `__Host-` cookie never is). */
|
|
37
|
+
export const readCookie = (header: string | undefined, name: string): string | undefined => {
|
|
38
|
+
const values = (header ?? "")
|
|
39
|
+
.split(";")
|
|
40
|
+
.map((pair) => pair.trim())
|
|
41
|
+
.filter((pair) => pair.startsWith(`${name}=`))
|
|
42
|
+
.map((pair) => pair.slice(name.length + 1));
|
|
43
|
+
return values.length === 1 ? values[0] : undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Step 1 of every request: the raw path, refused (null) if it holds a backslash, a control character, or a `.` or
|
|
48
|
+
* `..` segment, whether raw or percent-encoded, or does not decode. Otherwise the decoded path.
|
|
49
|
+
*/
|
|
50
|
+
export const normalizePath = (rawPath: string): string | null => {
|
|
51
|
+
if (!rawPath.startsWith("/") || unsafe(rawPath) || encodedUnsafe.test(rawPath)) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
let decoded: string;
|
|
55
|
+
try {
|
|
56
|
+
decoded = decodeURIComponent(rawPath);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (unsafe(decoded)) return null;
|
|
61
|
+
if (decoded.split("/").some((segment) => segment === "." || segment === "..")) return null;
|
|
62
|
+
return decoded;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The request target as received, split at its first `?`: the raw path, and the query with its `?`. */
|
|
66
|
+
export const splitTarget = (target: string): { rawPath: string; rawQuery: string } => {
|
|
67
|
+
const at = target.indexOf("?");
|
|
68
|
+
return at === -1 ? { rawPath: target, rawQuery: "" } : { rawPath: target.slice(0, at), rawQuery: target.slice(at) };
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** GET, HEAD and POST; the gate answers anything else with 405. */
|
|
72
|
+
const isGatedMethod = (method: string | undefined) => method === "GET" || method === "HEAD" || method === "POST";
|
|
73
|
+
|
|
74
|
+
/** The paths the gate answers itself, with or without a session: robots.txt and its own /_door/ endpoints. */
|
|
75
|
+
const isOwnPath = (path: string) => path === "/robots.txt" || path.startsWith("/_door/");
|
|
76
|
+
|
|
77
|
+
/** Whether a request is a page load, which gets the door, rather than a fetch, which gets a bare 401. */
|
|
78
|
+
export const isDocumentRequest = (request: IncomingMessage): boolean => {
|
|
79
|
+
if (request.method !== "GET" && request.method !== "HEAD") return false;
|
|
80
|
+
const mode = request.headers["sec-fetch-mode"];
|
|
81
|
+
if (mode !== undefined) return mode === "navigate";
|
|
82
|
+
return String(request.headers.accept ?? "").includes("text/html");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The form body, or null once it passes 16 KB. The rest of an oversized body is read and thrown away rather than the
|
|
87
|
+
* socket destroyed, so the visitor still gets the redirect back to the door; that response closes the connection.
|
|
88
|
+
*/
|
|
89
|
+
const readBody = (request: IncomingMessage): Promise<string | null> =>
|
|
90
|
+
new Promise((resolve, reject) => {
|
|
91
|
+
const chunks: Buffer[] = [];
|
|
92
|
+
let size = 0;
|
|
93
|
+
const collect = (chunk: Buffer) => {
|
|
94
|
+
size += chunk.length;
|
|
95
|
+
if (size > maxBodyBytes) {
|
|
96
|
+
request.off("data", collect);
|
|
97
|
+
chunks.length = 0;
|
|
98
|
+
request.resume();
|
|
99
|
+
resolve(null);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
chunks.push(chunk);
|
|
103
|
+
};
|
|
104
|
+
request.on("data", collect);
|
|
105
|
+
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
106
|
+
request.on("error", reject);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/** One form field, present exactly once. */
|
|
110
|
+
const field = (form: URLSearchParams, name: string): string | undefined => {
|
|
111
|
+
const values = form.getAll(name);
|
|
112
|
+
return values.length === 1 ? values[0] : undefined;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type GateOptions = {
|
|
116
|
+
config: GateConfig;
|
|
117
|
+
/** The static export the gate serves from. */
|
|
118
|
+
site: string;
|
|
119
|
+
/** Seconds since the epoch; the clock by default. */
|
|
120
|
+
now?: () => number;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The gate: every request to a locked site, in this order. (1) A path with a backslash, a control character or a dot
|
|
125
|
+
* segment, raw or encoded, is a 400; a method other than GET, HEAD or POST is a 405. (2) A host not on the list is a
|
|
126
|
+
* 403. (3) robots.txt disallows everything, and /_door/health answers ok. (4) POST /_door verifies a ticket from the
|
|
127
|
+
* door and sets the session. (5) /_door/signout clears both cookies and walks a fixed map. (6) A request with a valid
|
|
128
|
+
* session is served from the export. (7) Without one, a page load is sent to the door with a fresh state cookie and
|
|
129
|
+
* anything else gets 401. (8) An exception is a 500 with nothing in it: the gate fails closed.
|
|
130
|
+
*/
|
|
131
|
+
export const createGate = ({
|
|
132
|
+
config,
|
|
133
|
+
site,
|
|
134
|
+
now = () => Math.floor(Date.now() / 1000),
|
|
135
|
+
}: GateOptions): RequestListener => {
|
|
136
|
+
const empty = (response: ServerResponse, status: number, headers: Record<string, string | string[]> = {}) => {
|
|
137
|
+
response.writeHead(status, { ...securityHeaders, "Cache-Control": "no-store", "Content-Length": "0", ...headers });
|
|
138
|
+
response.end();
|
|
139
|
+
};
|
|
140
|
+
const redirect = (response: ServerResponse, status: number, location: string, cookies: string[] = []) =>
|
|
141
|
+
empty(response, status, { Location: location, ...(cookies.length > 0 && { "Set-Cookie": cookies }) });
|
|
142
|
+
const text = (response: ServerResponse, request: IncomingMessage, body: string) => {
|
|
143
|
+
response.writeHead(200, {
|
|
144
|
+
...securityHeaders,
|
|
145
|
+
"Cache-Control": "no-store",
|
|
146
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
147
|
+
"Content-Length": String(Buffer.byteLength(body)),
|
|
148
|
+
});
|
|
149
|
+
response.end(request.method === "HEAD" ? undefined : body);
|
|
150
|
+
};
|
|
151
|
+
const doorSignout = `${config.door}/signout/`;
|
|
152
|
+
|
|
153
|
+
/** Where `then` leads: the next stop's gate, or the door's sign-out page. Never a URL from the query. */
|
|
154
|
+
const signoutTarget = (then: string | null): string => {
|
|
155
|
+
const index = config.signout.findIndex((stop) => stop.id === then);
|
|
156
|
+
const stop = config.signout[index];
|
|
157
|
+
if (!stop) return doorSignout;
|
|
158
|
+
const following = config.signout[index + 1]?.id ?? "door";
|
|
159
|
+
return `https://${stop.host}/_door/signout?then=${following}`;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const toDoorWithError = (response: ServerResponse, close = false) =>
|
|
163
|
+
empty(response, 303, {
|
|
164
|
+
Location: `${config.door}/?error=ticket`,
|
|
165
|
+
"Set-Cookie": [clearState],
|
|
166
|
+
...(close && { Connection: "close" }),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
/** Step 4: a ticket the door posted, which becomes the session, or a refusal that sends the visitor back. */
|
|
170
|
+
const acceptTicket = async (request: IncomingMessage, response: ServerResponse, host: string) => {
|
|
171
|
+
if (request.method !== "POST") return empty(response, 405, { Allow: "POST" });
|
|
172
|
+
if (!String(request.headers["content-type"] ?? "").startsWith("application/x-www-form-urlencoded")) {
|
|
173
|
+
return toDoorWithError(response);
|
|
174
|
+
}
|
|
175
|
+
const body = await readBody(request);
|
|
176
|
+
if (body === null) return toDoorWithError(response, true);
|
|
177
|
+
const form = new URLSearchParams(body);
|
|
178
|
+
const state = readCookie(request.headers.cookie, stateCookie);
|
|
179
|
+
if (!state) return toDoorWithError(response);
|
|
180
|
+
const ticket = field(form, "ticket");
|
|
181
|
+
const verdict = verifyTicket(ticket, {
|
|
182
|
+
keys: config.keys,
|
|
183
|
+
issuer: config.issuer,
|
|
184
|
+
audience: host,
|
|
185
|
+
now: now(),
|
|
186
|
+
state,
|
|
187
|
+
});
|
|
188
|
+
if (!verdict.ok) return toDoorWithError(response);
|
|
189
|
+
const formNext = canonicalNext(field(form, "next"), host);
|
|
190
|
+
const claimNext = canonicalNext(verdict.claims.next, host);
|
|
191
|
+
if (formNext === null || claimNext === null || formNext !== claimNext) return toDoorWithError(response);
|
|
192
|
+
const maxAge = verdict.claims.exp - now();
|
|
193
|
+
return redirect(response, 303, claimNext, [cookie(sessionCookie, ticket ?? "", maxAge, "Lax"), clearState]);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/** Step 7: no session. A page load goes to the door with a fresh state; anything else is a bare 401. */
|
|
197
|
+
const refuse = (request: IncomingMessage, response: ServerResponse, target: string, host: string) => {
|
|
198
|
+
if (!isDocumentRequest(request)) return empty(response, 401);
|
|
199
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
200
|
+
const next = canonicalNext(target, host) ?? "/";
|
|
201
|
+
const query = new URLSearchParams({ site: config.site, host, next, state: nonce });
|
|
202
|
+
return redirect(response, 302, `${config.door}/?${query}`, [
|
|
203
|
+
cookie(stateCookie, nonce, stateLifetimeSeconds, "None"),
|
|
204
|
+
]);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Step 6: the export, as Amplify maps it, private to the browser that holds the session. */
|
|
208
|
+
const serveSite = async (
|
|
209
|
+
request: IncomingMessage,
|
|
210
|
+
response: ServerResponse,
|
|
211
|
+
path: string,
|
|
212
|
+
location: { rawPath: string; rawQuery: string; host: string },
|
|
213
|
+
) => {
|
|
214
|
+
const found = await staticTarget(site, path);
|
|
215
|
+
if (found.kind === "outside") return empty(response, 400);
|
|
216
|
+
if (found.kind === "directory") {
|
|
217
|
+
const withSlash = canonicalNext(`${location.rawPath}/${location.rawQuery}`, location.host);
|
|
218
|
+
return withSlash === null ? empty(response, 404) : redirect(response, 308, withSlash);
|
|
219
|
+
}
|
|
220
|
+
if (found.kind === "missing") {
|
|
221
|
+
return sendFile(request, response, found.notFound, 404, {
|
|
222
|
+
...securityHeaders,
|
|
223
|
+
"Cache-Control": "private, no-store",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const cacheControl = path.startsWith("/_next/static/")
|
|
227
|
+
? "private, max-age=31536000, immutable"
|
|
228
|
+
: "private, no-store";
|
|
229
|
+
return sendFile(request, response, found.file, 200, { ...securityHeaders, "Cache-Control": cacheControl });
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Steps 3 and 5: what the gate answers itself, which holds nothing of the site. */
|
|
233
|
+
const answerOwn = (request: IncomingMessage, response: ServerResponse, path: string, rawQuery: string) => {
|
|
234
|
+
if (path === "/robots.txt") return text(response, request, "User-agent: *\nDisallow: /\n");
|
|
235
|
+
if (path === "/_door/health") return text(response, request, "ok");
|
|
236
|
+
if (path === "/_door/signout") {
|
|
237
|
+
const then = new URLSearchParams(rawQuery).get("then");
|
|
238
|
+
return redirect(response, 303, signoutTarget(then), [clearSession, clearState]);
|
|
239
|
+
}
|
|
240
|
+
return empty(response, 404);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/** The host the request names, lowercased, if it is one this gate answers on. */
|
|
244
|
+
const allowedHost = (request: IncomingMessage): string | undefined => {
|
|
245
|
+
const value = request.headers[config.hostHeader];
|
|
246
|
+
const host = typeof value === "string" ? value.toLowerCase() : "";
|
|
247
|
+
return config.hosts.includes(host) ? host : undefined;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Whether the request carries a session the door issued for this host, still live. */
|
|
251
|
+
const hasSession = (request: IncomingMessage, host: string) => {
|
|
252
|
+
const session = readCookie(request.headers.cookie, sessionCookie);
|
|
253
|
+
return (
|
|
254
|
+
session !== undefined &&
|
|
255
|
+
verifyTicket(session, { keys: config.keys, issuer: config.issuer, audience: host, now: now() }).ok
|
|
256
|
+
);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const handle = async (request: IncomingMessage, response: ServerResponse) => {
|
|
260
|
+
const target = request.url ?? "";
|
|
261
|
+
const { rawPath, rawQuery } = splitTarget(target);
|
|
262
|
+
const path = normalizePath(rawPath);
|
|
263
|
+
if (path === null) return empty(response, 400);
|
|
264
|
+
if (!isGatedMethod(request.method)) return empty(response, 405, { Allow: "GET, HEAD, POST" });
|
|
265
|
+
const host = allowedHost(request);
|
|
266
|
+
if (host === undefined) return empty(response, 403);
|
|
267
|
+
|
|
268
|
+
if (path === "/_door") return acceptTicket(request, response, host);
|
|
269
|
+
if (request.method === "POST") return empty(response, 405, { Allow: "GET, HEAD" });
|
|
270
|
+
if (isOwnPath(path)) return answerOwn(request, response, path, rawQuery);
|
|
271
|
+
|
|
272
|
+
if (!hasSession(request, host)) return refuse(request, response, target, host);
|
|
273
|
+
return serveSite(request, response, path, { rawPath, rawQuery, host });
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return (request, response) => {
|
|
277
|
+
handle(request, response).catch((error: unknown) => {
|
|
278
|
+
console.error("gate: request failed", error instanceof Error ? error.name : typeof error);
|
|
279
|
+
if (response.headersSent) {
|
|
280
|
+
response.destroy();
|
|
281
|
+
} else {
|
|
282
|
+
empty(response, 500);
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export type StartGateOptions = {
|
|
289
|
+
/** gate.config.json. */
|
|
290
|
+
config: string | URL;
|
|
291
|
+
/** The static export. */
|
|
292
|
+
site: string | URL;
|
|
293
|
+
/** 3000 by default, the port Amplify's deployment specification names. */
|
|
294
|
+
port?: number;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/** Reads the configuration, refusing to start on a bad one, and listens. The bundle's server.mjs calls this. */
|
|
298
|
+
export const startGate = async ({ config, site, port = 3000 }: StartGateOptions): Promise<Server> => {
|
|
299
|
+
const parsed = await readGateConfig(config);
|
|
300
|
+
const root = typeof site === "string" ? site : fileURLToPath(site);
|
|
301
|
+
const server = createServer(createGate({ config: parsed, site: root }));
|
|
302
|
+
await new Promise<void>((resolve, reject) => {
|
|
303
|
+
server.once("error", reject);
|
|
304
|
+
server.listen(port, () => resolve());
|
|
305
|
+
});
|
|
306
|
+
console.log(`gate: ${parsed.site} listening on ${port}`);
|
|
307
|
+
return server;
|
|
308
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createHash, createPublicKey, type KeyObject, timingSafeEqual, verify } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/** The JWKS the door publishes: P-256 public keys by `kid`. */
|
|
4
|
+
export type Jwks = ReadonlyMap<string, KeyObject>;
|
|
5
|
+
|
|
6
|
+
/** What a door ticket, and the session cookie made from it, claims. */
|
|
7
|
+
export type DoorClaims = {
|
|
8
|
+
iss: string;
|
|
9
|
+
aud: string;
|
|
10
|
+
sub: string;
|
|
11
|
+
gid: string;
|
|
12
|
+
jti: string;
|
|
13
|
+
iat: number;
|
|
14
|
+
exp: number;
|
|
15
|
+
st?: string;
|
|
16
|
+
next?: string;
|
|
17
|
+
kid?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type Verdict = { ok: true; claims: DoorClaims; kid: string } | { ok: false; reason: string };
|
|
21
|
+
|
|
22
|
+
export type VerifyOptions = {
|
|
23
|
+
keys: Jwks;
|
|
24
|
+
/** The door's origin, which the ticket names as `iss`. */
|
|
25
|
+
issuer: string;
|
|
26
|
+
/** The host the request arrived on, which the ticket names as `aud`. */
|
|
27
|
+
audience: string;
|
|
28
|
+
/** Seconds since the epoch; the clock by default. */
|
|
29
|
+
now?: number;
|
|
30
|
+
/** The state cookie's value. Given, the ticket's `st` has to equal it; a session cookie is checked without it. */
|
|
31
|
+
state?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** The longest a ticket or session may live, from `iat` to `exp`. */
|
|
35
|
+
export const maxLifetimeSeconds = 3600;
|
|
36
|
+
/** How far ahead of this clock a ticket's `iat` may be, for a door whose clock runs a little fast. */
|
|
37
|
+
export const clockSkewSeconds = 60;
|
|
38
|
+
|
|
39
|
+
const base64url = /^[A-Za-z0-9_-]+$/;
|
|
40
|
+
const maxTokenLength = 4096;
|
|
41
|
+
|
|
42
|
+
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
43
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
|
|
45
|
+
const decodeJson = (part: string): unknown => {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const isText = (value: unknown, max = 256): value is string =>
|
|
54
|
+
typeof value === "string" && value.length > 0 && value.length <= max;
|
|
55
|
+
|
|
56
|
+
/** One JWKS entry, or a throw naming why it cannot be a door key. */
|
|
57
|
+
const readJwk = (jwk: unknown): [string, KeyObject] => {
|
|
58
|
+
if (!isObject(jwk)) throw new Error("a JWKS entry is not an object");
|
|
59
|
+
if ("d" in jwk) throw new Error("the JWKS holds a private key");
|
|
60
|
+
if (jwk.kty !== "EC" || jwk.crv !== "P-256") throw new Error("a JWKS key is not EC on P-256");
|
|
61
|
+
if (jwk.alg !== undefined && jwk.alg !== "ES256") throw new Error("a JWKS key is not for ES256");
|
|
62
|
+
if (jwk.use !== undefined && jwk.use !== "sig") throw new Error("a JWKS key is not for signing");
|
|
63
|
+
if (!isText(jwk.kid)) throw new Error("a JWKS key has no kid");
|
|
64
|
+
if (!isText(jwk.x, 64) || !isText(jwk.y, 64)) throw new Error("a JWKS key has no coordinates");
|
|
65
|
+
const key = createPublicKey({ key: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y }, format: "jwk" });
|
|
66
|
+
if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") {
|
|
67
|
+
throw new Error("a JWKS key is not EC on P-256");
|
|
68
|
+
}
|
|
69
|
+
return [jwk.kid, key];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Reads a JWKS and keeps only what a door key can be: EC on P-256, for signing with ES256, with a `kid`, and public.
|
|
74
|
+
* Anything else throws, because a gate built with a key it cannot use, or with a private one, must not ship.
|
|
75
|
+
*/
|
|
76
|
+
export const parseJwks = (value: unknown): Jwks => {
|
|
77
|
+
if (!isObject(value) || !Array.isArray(value.keys) || value.keys.length === 0) {
|
|
78
|
+
throw new Error("the JWKS has no keys");
|
|
79
|
+
}
|
|
80
|
+
const keys = new Map<string, KeyObject>();
|
|
81
|
+
for (const entry of value.keys) {
|
|
82
|
+
const [kid, key] = readJwk(entry);
|
|
83
|
+
if (keys.has(kid)) throw new Error(`a JWKS kid is used twice: ${kid}`);
|
|
84
|
+
keys.set(kid, key);
|
|
85
|
+
}
|
|
86
|
+
return keys;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Equal strings, compared in time that depends on neither value: both are hashed to the same length first. */
|
|
90
|
+
const sameSecret = (a: string, b: string) =>
|
|
91
|
+
timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
|
|
92
|
+
|
|
93
|
+
type Signed = { ok: true; kid: string; payload: unknown } | { ok: false; reason: string };
|
|
94
|
+
|
|
95
|
+
/** The JWS's shape, header and signature, checked before anything in its payload is believed. */
|
|
96
|
+
const checkSignature = (token: unknown, keys: Jwks): Signed => {
|
|
97
|
+
const refuse = (reason: string): Signed => ({ ok: false, reason });
|
|
98
|
+
if (typeof token !== "string" || token.length > maxTokenLength) return refuse("shape");
|
|
99
|
+
const parts = token.split(".");
|
|
100
|
+
if (parts.length !== 3 || !parts.every((part) => base64url.test(part))) return refuse("shape");
|
|
101
|
+
const [encodedHeader = "", encodedPayload = "", encodedSignature = ""] = parts;
|
|
102
|
+
const header = decodeJson(encodedHeader);
|
|
103
|
+
if (!isObject(header) || header.alg !== "ES256" || "crit" in header) return refuse("header");
|
|
104
|
+
if (header.typ !== undefined && header.typ !== "JWT") return refuse("header");
|
|
105
|
+
if (!isText(header.kid)) return refuse("header");
|
|
106
|
+
const key = keys.get(header.kid);
|
|
107
|
+
if (!key) return refuse("kid");
|
|
108
|
+
const signature = Buffer.from(encodedSignature, "base64url");
|
|
109
|
+
if (signature.length !== 64) return refuse("signature");
|
|
110
|
+
const signed = Buffer.from(`${encodedHeader}.${encodedPayload}`, "ascii");
|
|
111
|
+
if (!verify("sha256", signed, { key, dsaEncoding: "ieee-p1363" }, signature)) return refuse("signature");
|
|
112
|
+
return { ok: true, kid: header.kid, payload: decodeJson(encodedPayload) };
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Whether `iat` and `exp` make a live ticket of at most an hour: a reason to refuse, or undefined. */
|
|
116
|
+
const checkTimes = (iat: unknown, exp: unknown, now: number): string | undefined => {
|
|
117
|
+
if (typeof iat !== "number" || typeof exp !== "number" || !Number.isInteger(iat) || !Number.isInteger(exp)) {
|
|
118
|
+
return "time";
|
|
119
|
+
}
|
|
120
|
+
if (exp <= now) return "expired";
|
|
121
|
+
if (iat > now + clockSkewSeconds || exp <= iat || exp - iat > maxLifetimeSeconds) return "time";
|
|
122
|
+
return undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The claims of a signed token, against what this gate expects: a reason to refuse, or undefined. */
|
|
126
|
+
const checkClaims = (claims: Record<string, unknown>, kid: string, options: VerifyOptions): string | undefined => {
|
|
127
|
+
if (claims.iss !== options.issuer) return "iss";
|
|
128
|
+
if (claims.aud !== options.audience) return "aud";
|
|
129
|
+
if (!isText(claims.sub) || !isText(claims.gid) || !isText(claims.jti)) return "subject";
|
|
130
|
+
const late = checkTimes(claims.iat, claims.exp, options.now ?? Math.floor(Date.now() / 1000));
|
|
131
|
+
if (late) return late;
|
|
132
|
+
if (claims.kid !== undefined && claims.kid !== kid) return "kid";
|
|
133
|
+
if (claims.next !== undefined && typeof claims.next !== "string") return "next";
|
|
134
|
+
if (options.state !== undefined && !(isText(claims.st, 128) && sameSecret(claims.st, options.state))) return "state";
|
|
135
|
+
return undefined;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Verifies a door ticket or session: an ES256 JWS whose `kid` is in the JWKS, signed by that key, issued by the door
|
|
140
|
+
* for this exact host, unexpired, living no longer than an hour, and (for a ticket) bound to the state cookie. The
|
|
141
|
+
* signature is checked before any claim is read. Every refusal is the same refusal to the visitor; `reason` is for
|
|
142
|
+
* tests.
|
|
143
|
+
*/
|
|
144
|
+
export const verifyTicket = (token: unknown, options: VerifyOptions): Verdict => {
|
|
145
|
+
const refuse = (reason: string): Verdict => ({ ok: false, reason });
|
|
146
|
+
const signed = checkSignature(token, options.keys);
|
|
147
|
+
if (!signed.ok) return signed;
|
|
148
|
+
const claims = signed.payload;
|
|
149
|
+
if (!isObject(claims)) return refuse("payload");
|
|
150
|
+
const reason = checkClaims(claims, signed.kid, options);
|
|
151
|
+
return reason ? refuse(reason) : { ok: true, claims: claims as DoorClaims, kid: signed.kid };
|
|
152
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
export {
|
|
2
|
+
doorOrigin,
|
|
3
|
+
type GateConfig,
|
|
4
|
+
type GateConfigFile,
|
|
5
|
+
parseGateConfig,
|
|
6
|
+
parseSignoutChain,
|
|
7
|
+
readGateConfig,
|
|
8
|
+
type SignoutStop,
|
|
9
|
+
} from "./door/config.ts";
|
|
10
|
+
export { canonicalNext } from "./door/next.ts";
|
|
11
|
+
export {
|
|
12
|
+
type BundleDoorOptions,
|
|
13
|
+
bundleDoor,
|
|
14
|
+
checkDeployment,
|
|
15
|
+
type DeployManifest,
|
|
16
|
+
fetchJwks,
|
|
17
|
+
gateFiles,
|
|
18
|
+
maxBundleBytes,
|
|
19
|
+
} from "./door/package.ts";
|
|
20
|
+
export {
|
|
21
|
+
createGate,
|
|
22
|
+
type GateOptions,
|
|
23
|
+
type StartGateOptions,
|
|
24
|
+
sessionCookie,
|
|
25
|
+
startGate,
|
|
26
|
+
stateCookie,
|
|
27
|
+
} from "./door/server.ts";
|
|
28
|
+
export {
|
|
29
|
+
type DoorClaims,
|
|
30
|
+
type Jwks,
|
|
31
|
+
parseJwks,
|
|
32
|
+
type Verdict,
|
|
33
|
+
type VerifyOptions,
|
|
34
|
+
verifyTicket,
|
|
35
|
+
} from "./door/verify.ts";
|
|
1
36
|
export {
|
|
2
37
|
type Category,
|
|
3
38
|
categories,
|
|
@@ -7,4 +42,13 @@ export {
|
|
|
7
42
|
reportBase,
|
|
8
43
|
runLighthouse,
|
|
9
44
|
} from "./lighthouse.ts";
|
|
10
|
-
export {
|
|
45
|
+
export {
|
|
46
|
+
contentTypeOf,
|
|
47
|
+
createStaticServer,
|
|
48
|
+
encodingFor,
|
|
49
|
+
type ServeOptions,
|
|
50
|
+
type StaticTarget,
|
|
51
|
+
sendFile,
|
|
52
|
+
serve,
|
|
53
|
+
staticTarget,
|
|
54
|
+
} from "./serve.ts";
|