@antelopejs/dms-frontend 0.5.5 → 0.6.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 +377 -19
- package/dist/app-configs.d.ts +9 -0
- package/dist/app-configs.js +52 -0
- package/dist/asset-roots.d.ts +7 -0
- package/dist/asset-roots.js +34 -0
- package/dist/commands/build-action.js +9 -0
- package/dist/commands/dev-action.js +88 -19
- package/dist/commands/start-action.js +2 -0
- package/dist/common.d.ts +1 -0
- package/dist/common.js +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -3
- package/dist/derived-outputs.d.ts +2 -0
- package/dist/derived-outputs.js +38 -31
- package/dist/help.js +10 -3
- package/dist/layer-watch.js +123 -131
- package/dist/layers.js +2 -0
- package/dist/manifest-secrets.js +5 -5
- package/dist/manifest.js +7 -1
- package/dist/materialize.js +21 -1
- package/dist/module-set.d.ts +16 -0
- package/dist/module-set.js +80 -0
- package/dist/server-process.d.ts +1 -0
- package/dist/server-process.js +28 -2
- package/dist/verify-source-build.d.ts +11 -0
- package/dist/verify-source-build.js +113 -0
- package/dist/verify-source-runner.js +14 -57
- package/dist/workspace.d.ts +9 -0
- package/package.json +2 -1
- package/templates/vue/app-runtime.ts +58 -16
- package/templates/vue/client-server-guard.mjs +73 -0
- package/templates/vue/email-renderer.ts +17 -4
- package/templates/vue/email-runtime.ts +1 -1
- package/templates/vue/frontend-module.ts +1817 -259
- package/templates/vue/globals.d.ts +1 -1
- package/templates/vue/icon-hydration.ts +106 -1
- package/templates/vue/index.html +0 -1
- package/templates/vue/layer-app-config.ts +92 -0
- package/templates/vue/main.ts +10 -2
- package/templates/vue/package.json +5 -1
- package/templates/vue/prepare-types.mjs +34 -0
- package/templates/vue/server/auth/backend.mjs +13 -19
- package/templates/vue/server/auth/client-ip.mjs +27 -0
- package/templates/vue/server/auth/establish-endpoints.mjs +70 -0
- package/templates/vue/server/auth/session.mjs +6 -97
- package/templates/vue/server/auth-hooks.mjs +84 -0
- package/templates/vue/server/backend-proxies.mjs +110 -0
- package/templates/vue/server/dev-private-files.mjs +99 -0
- package/templates/vue/server/email.mjs +11 -33
- package/templates/vue/server/favicon.mjs +20 -0
- package/templates/vue/server/inertia.mjs +48 -3
- package/templates/vue/server/listen-host.mjs +19 -0
- package/templates/vue/server/module-event.mjs +265 -0
- package/templates/vue/server/module-routes.mjs +513 -0
- package/templates/vue/server/module-server.mjs +225 -0
- package/templates/vue/server/module-testing.mjs +65 -0
- package/templates/vue/server/page-props.mjs +151 -0
- package/templates/vue/server/path-patterns.mjs +38 -0
- package/templates/vue/server/proxy-headers.mjs +25 -0
- package/templates/vue/server/public-endpoints.mjs +69 -11
- package/templates/vue/server/request-routing.mjs +47 -0
- package/templates/vue/server/route-rules.mjs +129 -0
- package/templates/vue/server-module.ts +294 -0
- package/templates/vue/server.mjs +178 -169
- package/templates/vue/ssr-renderer.ts +60 -19
- package/templates/vue/tsconfig.json +2 -1
- package/templates/vue/ui-app-config.ts +18 -6
- package/templates/vue/ui-inertia-page.mjs +37 -0
- package/templates/vue/vite.config.ts +31 -10
- package/templates/vue/vite.email.config.ts +1 -1
- package/templates/vue/vite.server.config.ts +57 -0
- package/templates/vue/server/auth/oauth.mjs +0 -233
- package/templates/vue/server/auth/routes.mjs +0 -444
- package/templates/vue/server/render-token.mjs +0 -81
- package/templates/vue/server/tester.mjs +0 -227
|
@@ -1,444 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { backend, body, json, UpstreamError } from "./backend.mjs";
|
|
4
|
-
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./client-ip.mjs";
|
|
5
|
-
import {
|
|
6
|
-
oauthCallback,
|
|
7
|
-
oauthHandoff,
|
|
8
|
-
oauthStart,
|
|
9
|
-
sessionFrom,
|
|
10
|
-
} from "./oauth.mjs";
|
|
11
|
-
import {
|
|
12
|
-
clearSealedCookie,
|
|
13
|
-
clearSession,
|
|
14
|
-
isPersistent,
|
|
15
|
-
persistAccountSession,
|
|
16
|
-
readAccount,
|
|
17
|
-
readSealedCookie,
|
|
18
|
-
readSession,
|
|
19
|
-
removeAccount,
|
|
20
|
-
setRequestSession,
|
|
21
|
-
storeAccount,
|
|
22
|
-
writeSealedCookie,
|
|
23
|
-
} from "./session.mjs";
|
|
24
|
-
|
|
25
|
-
const REFRESH_TTL_MS = 15_000;
|
|
26
|
-
const refreshes = new Map();
|
|
27
|
-
|
|
28
|
-
// Carries the "keep me signed in" choice of a login across its 2FA step, whose
|
|
29
|
-
// request names only the challenge. Lives as long as the challenge token.
|
|
30
|
-
const PENDING_LOGIN = "dms_pending_login";
|
|
31
|
-
const PENDING_LOGIN_PATH = "/auth/verify-2fa";
|
|
32
|
-
const PENDING_LOGIN_LIFETIME_S = 300;
|
|
33
|
-
|
|
34
|
-
const PASSTHROUGH = new Map([
|
|
35
|
-
["/auth/request-2fa-email", "/api/auth/request-2fa-email"],
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
// Absolute backend API paths only: no scheme, no authority, no query string,
|
|
39
|
-
// and no segment that could climb out of `/api/`.
|
|
40
|
-
const BACKEND_API_PATH =
|
|
41
|
-
/^\/api\/[A-Za-z0-9][A-Za-z0-9._~-]*(?:\/[A-Za-z0-9][A-Za-z0-9._~-]*)*$/;
|
|
42
|
-
|
|
43
|
-
// Written at the workspace root by the loader, from the endpoints each backend
|
|
44
|
-
// module declared in the frontend manifest. This file sits two directories up.
|
|
45
|
-
const DECLARED_ENDPOINTS_FILE = new URL(
|
|
46
|
-
"../../generated-auth-establish.json",
|
|
47
|
-
import.meta.url,
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
let declaredEndpoints;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Backend endpoints the modules of this deployment declared as session-opening.
|
|
54
|
-
*
|
|
55
|
-
* Read from the workspace rather than from the backend: the file is generated
|
|
56
|
-
* when the workspace is materialized, so the server needs no backend round trip
|
|
57
|
-
* — and no operator — to know which module flows may end on a login. A
|
|
58
|
-
* workspace built from a DMS that predates the declaration simply has no file,
|
|
59
|
-
* and the list falls back to whatever the environment names.
|
|
60
|
-
*
|
|
61
|
-
* @param file Generated file to read
|
|
62
|
-
* @returns The declared paths, unfiltered
|
|
63
|
-
*/
|
|
64
|
-
export function readDeclaredEstablishEndpoints(file = DECLARED_ENDPOINTS_FILE) {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
67
|
-
return Array.isArray(parsed?.endpoints) ? parsed.endpoints : [];
|
|
68
|
-
} catch {
|
|
69
|
-
return [];
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function publicSession(session) {
|
|
74
|
-
if (!session) return {};
|
|
75
|
-
return {
|
|
76
|
-
user: session.user,
|
|
77
|
-
session: {
|
|
78
|
-
accountId: session.accountId,
|
|
79
|
-
activeTenantId: session.activeTenantId,
|
|
80
|
-
updatedAt: session.updatedAt,
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function refreshDigest(token) {
|
|
86
|
-
return createHash("sha256").update(token).digest("base64url");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function singleFlight(token, operation) {
|
|
90
|
-
const now = Date.now();
|
|
91
|
-
for (const [key, entry] of refreshes)
|
|
92
|
-
if (
|
|
93
|
-
entry.expiresAt &&
|
|
94
|
-
entry.expiresAt <= now &&
|
|
95
|
-
refreshes.get(key) === entry
|
|
96
|
-
)
|
|
97
|
-
refreshes.delete(key);
|
|
98
|
-
const key = refreshDigest(token);
|
|
99
|
-
const current = refreshes.get(key);
|
|
100
|
-
if (current) return current.promise;
|
|
101
|
-
const entry = { promise: undefined, expiresAt: undefined };
|
|
102
|
-
const promise = Promise.resolve().then(operation);
|
|
103
|
-
entry.promise = promise;
|
|
104
|
-
refreshes.set(key, entry);
|
|
105
|
-
const retain = () => {
|
|
106
|
-
entry.expiresAt = Date.now() + REFRESH_TTL_MS;
|
|
107
|
-
setTimeout(() => {
|
|
108
|
-
if (refreshes.get(key) === entry) refreshes.delete(key);
|
|
109
|
-
}, REFRESH_TTL_MS).unref();
|
|
110
|
-
};
|
|
111
|
-
promise.then(retain, retain);
|
|
112
|
-
return promise;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function establishWith(
|
|
116
|
-
request,
|
|
117
|
-
response,
|
|
118
|
-
endpoint,
|
|
119
|
-
payload,
|
|
120
|
-
persistent = true,
|
|
121
|
-
) {
|
|
122
|
-
const result = await backend(endpoint, request, {
|
|
123
|
-
method: "POST",
|
|
124
|
-
body: payload,
|
|
125
|
-
});
|
|
126
|
-
if (result.requires_2fa && typeof result.two_factor_token === "string")
|
|
127
|
-
writeSealedCookie(
|
|
128
|
-
response,
|
|
129
|
-
PENDING_LOGIN,
|
|
130
|
-
{ token: result.two_factor_token, persistent },
|
|
131
|
-
PENDING_LOGIN_LIFETIME_S,
|
|
132
|
-
PENDING_LOGIN_PATH,
|
|
133
|
-
);
|
|
134
|
-
if (result.requires_2fa || result.requires_tenant_assignment)
|
|
135
|
-
return json(response, 200, result);
|
|
136
|
-
if (readSealedCookie(request, PENDING_LOGIN))
|
|
137
|
-
clearSealedCookie(response, PENDING_LOGIN, PENDING_LOGIN_PATH);
|
|
138
|
-
const session = sessionFrom(result, persistent);
|
|
139
|
-
const account = persistAccountSession(request, response, session);
|
|
140
|
-
json(response, 200, { user: result.user, account });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
async function establish(request, response, endpoint) {
|
|
144
|
-
return establishWith(request, response, endpoint, await body(request));
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Sign in with a password. Only a login that asked to be kept signed in
|
|
149
|
-
* outlives the browser; any other ends when the browser closes.
|
|
150
|
-
*/
|
|
151
|
-
async function login(request, response) {
|
|
152
|
-
const input = await body(request);
|
|
153
|
-
return establishWith(
|
|
154
|
-
request,
|
|
155
|
-
response,
|
|
156
|
-
"/api/auth/login",
|
|
157
|
-
input,
|
|
158
|
-
input.keep_login === true,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Finish a login on its 2FA step, with the lifetime the login chose. A
|
|
164
|
-
* challenge that no login of this browser left a choice for, such as the one
|
|
165
|
-
* an OAuth sign-in hands over, keeps the default.
|
|
166
|
-
*/
|
|
167
|
-
async function verifyTwoFactor(request, response) {
|
|
168
|
-
const input = await body(request);
|
|
169
|
-
const pending = readSealedCookie(request, PENDING_LOGIN);
|
|
170
|
-
const persistent =
|
|
171
|
-
typeof input.token === "string" && pending?.token === input.token
|
|
172
|
-
? isPersistent(pending)
|
|
173
|
-
: true;
|
|
174
|
-
return establishWith(
|
|
175
|
-
request,
|
|
176
|
-
response,
|
|
177
|
-
"/api/auth/verify-2fa",
|
|
178
|
-
input,
|
|
179
|
-
persistent,
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Backend endpoints this deployment lets a module open a session from.
|
|
185
|
-
*
|
|
186
|
-
* The union of what the backend's modules declared — the standard case, which
|
|
187
|
-
* needs no configuration — and what the environment adds on top, which is how
|
|
188
|
-
* an operator extends the list for a route no module owns. Everything else
|
|
189
|
-
* stays refused, so no backend endpoint that happens to mint a token pair can
|
|
190
|
-
* be turned into a login by a request from the browser.
|
|
191
|
-
*
|
|
192
|
-
* @param declaration Comma-separated absolute backend paths from the environment
|
|
193
|
-
* @param declared Paths declared by the backend's frontend modules
|
|
194
|
-
* @returns The well-formed backend API paths, without duplicates
|
|
195
|
-
*/
|
|
196
|
-
export function allowedEstablishEndpoints(
|
|
197
|
-
declaration = process.env.DMS_AUTH_ESTABLISH_ENDPOINTS,
|
|
198
|
-
declared = (declaredEndpoints ??= readDeclaredEstablishEndpoints()),
|
|
199
|
-
) {
|
|
200
|
-
const fromEnvironment = (declaration ?? "")
|
|
201
|
-
.split(",")
|
|
202
|
-
.map((entry) => entry.trim());
|
|
203
|
-
return [
|
|
204
|
-
...new Set(
|
|
205
|
-
[...declared, ...fromEnvironment].filter(
|
|
206
|
-
(entry) => typeof entry === "string" && BACKEND_API_PATH.test(entry),
|
|
207
|
-
),
|
|
208
|
-
),
|
|
209
|
-
];
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/**
|
|
213
|
-
* Whether a caller-named endpoint is one of the declared ones.
|
|
214
|
-
*
|
|
215
|
-
* The grammar is checked on the caller's value too, so a declaration that was
|
|
216
|
-
* never meant to be a prefix cannot be widened by a traversal or a query
|
|
217
|
-
* string smuggled into the request.
|
|
218
|
-
*
|
|
219
|
-
* @param endpoint Endpoint the browser asked to establish a session from
|
|
220
|
-
* @param allowed Declared endpoints
|
|
221
|
-
* @returns True when the endpoint may be called
|
|
222
|
-
*/
|
|
223
|
-
export function isAllowedEstablishEndpoint(endpoint, allowed) {
|
|
224
|
-
return (
|
|
225
|
-
typeof endpoint === "string" &&
|
|
226
|
-
BACKEND_API_PATH.test(endpoint) &&
|
|
227
|
-
allowed.includes(endpoint)
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Open a session from a backend endpoint that mints a token pair.
|
|
233
|
-
*
|
|
234
|
-
* The generic half of `/auth/login`: a module whose own flow ends in an
|
|
235
|
-
* authenticated user — a self-service registration completing, an invitation
|
|
236
|
-
* being redeemed — points this at the backend route that finishes it, and the
|
|
237
|
-
* session cookie is written from the tokens the loader fetched itself. The
|
|
238
|
-
* browser never carries a token: it names an endpoint and a payload, and gets
|
|
239
|
-
* back the same `{ user, account }` the login route answers.
|
|
240
|
-
*/
|
|
241
|
-
async function establishFromEndpoint(request, response) {
|
|
242
|
-
const input = await body(request);
|
|
243
|
-
if (!isAllowedEstablishEndpoint(input.endpoint, allowedEstablishEndpoints()))
|
|
244
|
-
return json(response, 403, { error: "Forbidden" });
|
|
245
|
-
return establishWith(request, response, input.endpoint, input.payload ?? {});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export async function refreshSession(request, response) {
|
|
249
|
-
const session = readSession(request);
|
|
250
|
-
if (!session?.refreshToken) return undefined;
|
|
251
|
-
try {
|
|
252
|
-
const tokens = await singleFlight(session.refreshToken, () =>
|
|
253
|
-
backend("/api/auth/refresh", request, {
|
|
254
|
-
method: "POST",
|
|
255
|
-
token: session.accessToken,
|
|
256
|
-
body: { token: session.refreshToken },
|
|
257
|
-
}),
|
|
258
|
-
);
|
|
259
|
-
const user = await backend("/api/auth/me", request, {
|
|
260
|
-
token: tokens.access_token,
|
|
261
|
-
});
|
|
262
|
-
const refreshed = sessionFrom({ ...tokens, user }, isPersistent(session));
|
|
263
|
-
persistAccountSession(request, response, refreshed, session.accountId);
|
|
264
|
-
setRequestSession(request, refreshed);
|
|
265
|
-
return refreshed;
|
|
266
|
-
} catch (error) {
|
|
267
|
-
const rejected =
|
|
268
|
-
error instanceof UpstreamError && [400, 401, 403].includes(error.status);
|
|
269
|
-
if (rejected) clearSession(response);
|
|
270
|
-
setRequestSession(request, undefined);
|
|
271
|
-
return undefined;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function refresh(request, response) {
|
|
276
|
-
json(response, 200, publicSession(await refreshSession(request, response)));
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function logout(request, response) {
|
|
280
|
-
const session = readSession(request);
|
|
281
|
-
if (session?.refreshToken && session?.accessToken)
|
|
282
|
-
await backend("/api/auth/logout", request, {
|
|
283
|
-
method: "POST",
|
|
284
|
-
token: session.accessToken,
|
|
285
|
-
body: { token: session.refreshToken },
|
|
286
|
-
}).catch(() => {});
|
|
287
|
-
clearSession(response);
|
|
288
|
-
if (session?.accountId) removeAccount(request, response, session.accountId);
|
|
289
|
-
json(response, 200, {});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
async function removeStoredAccount(request, response) {
|
|
293
|
-
const input = await body(request);
|
|
294
|
-
removeAccount(request, response, input.accountId);
|
|
295
|
-
json(response, 200, { success: true });
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
async function switchTenant(request, response) {
|
|
299
|
-
const session = readSession(request);
|
|
300
|
-
const input = await body(request);
|
|
301
|
-
if (!session) return json(response, 401, { message: "Not authenticated" });
|
|
302
|
-
if (!input.tenantId)
|
|
303
|
-
return json(response, 400, { message: "tenantId required" });
|
|
304
|
-
const tokens = await backend("/api/auth/switch-tenant", request, {
|
|
305
|
-
method: "POST",
|
|
306
|
-
token: session.accessToken,
|
|
307
|
-
body: { refreshToken: session.refreshToken, tenantId: input.tenantId },
|
|
308
|
-
});
|
|
309
|
-
const user = await backend("/api/auth/me", request, {
|
|
310
|
-
token: tokens.access_token,
|
|
311
|
-
});
|
|
312
|
-
const switched = sessionFrom({ ...tokens, user }, isPersistent(session));
|
|
313
|
-
persistAccountSession(request, response, switched, session.accountId);
|
|
314
|
-
json(response, 200, { success: true, user });
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
async function switchAccount(request, response) {
|
|
318
|
-
const input = await body(request);
|
|
319
|
-
const account = readAccount(request, input.accountId);
|
|
320
|
-
if (!account) return json(response, 401, { message: "Account expired" });
|
|
321
|
-
try {
|
|
322
|
-
const tokens = await backend("/api/auth/refresh", request, {
|
|
323
|
-
method: "POST",
|
|
324
|
-
body: { token: account.refreshToken },
|
|
325
|
-
});
|
|
326
|
-
const user = await backend("/api/auth/me", request, {
|
|
327
|
-
token: tokens.access_token,
|
|
328
|
-
});
|
|
329
|
-
const session = sessionFrom({ ...tokens, user }, isPersistent(account));
|
|
330
|
-
const descriptor = persistAccountSession(
|
|
331
|
-
request,
|
|
332
|
-
response,
|
|
333
|
-
session,
|
|
334
|
-
input.accountId,
|
|
335
|
-
);
|
|
336
|
-
json(response, 200, { success: true, user, account: descriptor });
|
|
337
|
-
} catch (error) {
|
|
338
|
-
if (
|
|
339
|
-
error instanceof UpstreamError &&
|
|
340
|
-
[400, 401, 403].includes(error.status)
|
|
341
|
-
)
|
|
342
|
-
removeAccount(request, response, input.accountId);
|
|
343
|
-
throw error;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/**
|
|
348
|
-
* Keep the tokens a validation refreshed, without changing which account is
|
|
349
|
-
* signed in. Another account only has its own cookie updated. The current one
|
|
350
|
-
* also has the session updated, since a refresh may have rotated the token the
|
|
351
|
-
* session holds; it keeps its user as the backend gave it.
|
|
352
|
-
*/
|
|
353
|
-
function storeValidatedAccount(request, response, account, accountId, tokens) {
|
|
354
|
-
const current = readSession(request);
|
|
355
|
-
if (current?.accountId === accountId) {
|
|
356
|
-
persistAccountSession(
|
|
357
|
-
request,
|
|
358
|
-
response,
|
|
359
|
-
sessionFrom({ ...tokens, user: current.user }, isPersistent(current)),
|
|
360
|
-
accountId,
|
|
361
|
-
);
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const user = {
|
|
365
|
-
_id: account.userId,
|
|
366
|
-
email: account.email,
|
|
367
|
-
name: account.name,
|
|
368
|
-
};
|
|
369
|
-
storeAccount(
|
|
370
|
-
request,
|
|
371
|
-
response,
|
|
372
|
-
sessionFrom({ ...tokens, user }, isPersistent(account)),
|
|
373
|
-
accountId,
|
|
374
|
-
);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
async function validateAccount(request, response) {
|
|
378
|
-
const input = await body(request);
|
|
379
|
-
const account = readAccount(request, input.accountId);
|
|
380
|
-
if (!account) return json(response, 200, { valid: false });
|
|
381
|
-
try {
|
|
382
|
-
const tokens = await backend("/api/auth/refresh", request, {
|
|
383
|
-
method: "POST",
|
|
384
|
-
body: { token: account.refreshToken },
|
|
385
|
-
});
|
|
386
|
-
storeValidatedAccount(request, response, account, input.accountId, tokens);
|
|
387
|
-
json(response, 200, { valid: true });
|
|
388
|
-
} catch (error) {
|
|
389
|
-
const rejected =
|
|
390
|
-
error instanceof UpstreamError && [400, 401, 403].includes(error.status);
|
|
391
|
-
if (rejected) removeAccount(request, response, input.accountId);
|
|
392
|
-
json(response, 200, { valid: rejected ? false : null });
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const actions = {
|
|
397
|
-
"/auth/login": login,
|
|
398
|
-
"/auth/signup": (request, response) =>
|
|
399
|
-
establish(request, response, "/api/auth/signup"),
|
|
400
|
-
"/auth/verify-2fa": verifyTwoFactor,
|
|
401
|
-
"/auth/establish": establishFromEndpoint,
|
|
402
|
-
"/auth/switch-account": switchAccount,
|
|
403
|
-
"/auth/switch-tenant": switchTenant,
|
|
404
|
-
"/auth/validate-account": validateAccount,
|
|
405
|
-
"/auth/remove-account": removeStoredAccount,
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
export async function handleAuth(request, response, url) {
|
|
409
|
-
if (url.pathname === "/api/_auth/session") {
|
|
410
|
-
if (request.method === "GET")
|
|
411
|
-
return json(response, 200, publicSession(readSession(request)));
|
|
412
|
-
if (!["POST", "DELETE"].includes(request.method)) {
|
|
413
|
-
response.setHeader("allow", "GET, POST, DELETE");
|
|
414
|
-
return json(response, 405, { error: "Method Not Allowed" });
|
|
415
|
-
}
|
|
416
|
-
if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
|
|
417
|
-
return request.method === "DELETE"
|
|
418
|
-
? logout(request, response)
|
|
419
|
-
: refresh(request, response);
|
|
420
|
-
}
|
|
421
|
-
const match = url.pathname.match(
|
|
422
|
-
/^\/auth\/oauth\/([^/]+)\/(start|callback)$/,
|
|
423
|
-
);
|
|
424
|
-
if (match)
|
|
425
|
-
return match[2] === "start"
|
|
426
|
-
? oauthStart(request, response, match[1], url)
|
|
427
|
-
: oauthCallback(request, response, match[1], url);
|
|
428
|
-
if (request.method !== "POST")
|
|
429
|
-
return json(response, 403, { error: "Forbidden", reason: "POST required" });
|
|
430
|
-
if (!isSameOrigin(request)) return json(response, 403, CROSS_ORIGIN_ERROR);
|
|
431
|
-
if (url.pathname === "/auth/oauth/handoff")
|
|
432
|
-
return oauthHandoff(request, response);
|
|
433
|
-
const endpoint = PASSTHROUGH.get(url.pathname);
|
|
434
|
-
if (endpoint)
|
|
435
|
-
return json(
|
|
436
|
-
response,
|
|
437
|
-
200,
|
|
438
|
-
await backend(endpoint, request, {
|
|
439
|
-
method: "POST",
|
|
440
|
-
body: await body(request),
|
|
441
|
-
}),
|
|
442
|
-
);
|
|
443
|
-
return actions[url.pathname]?.(request, response);
|
|
444
|
-
}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
// Verification for the service token that guards the HTML render route.
|
|
2
|
-
//
|
|
3
|
-
// Lives apart from server.mjs so the file stays under the size the linter
|
|
4
|
-
// allows, and so the three concerns the check actually has -- signature,
|
|
5
|
-
// claims, replay -- are named rather than run together in one function.
|
|
6
|
-
|
|
7
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
8
|
-
|
|
9
|
-
const RENDER_TOKEN_LIFETIME_SECONDS = 5 * 60;
|
|
10
|
-
const RENDER_REPLAY_LIMIT = 10_000;
|
|
11
|
-
|
|
12
|
-
/** jti -> exp, for tokens already spent. Pruned on every accepted token. */
|
|
13
|
-
const usedRenderTokens = new Map();
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Constant-time check of the token's HMAC. The length comparison guards
|
|
17
|
-
* timingSafeEqual, which throws on mismatched lengths -- it is not itself a
|
|
18
|
-
* timing leak, since the length of a base64url HMAC is fixed.
|
|
19
|
-
*/
|
|
20
|
-
function signatureValid(parts, secret) {
|
|
21
|
-
const expected = createHmac("sha256", secret)
|
|
22
|
-
.update(`${parts[0]}.${parts[1]}`)
|
|
23
|
-
.digest("base64url");
|
|
24
|
-
if (expected.length !== parts[2].length) return false;
|
|
25
|
-
return timingSafeEqual(Buffer.from(expected), Buffer.from(parts[2]));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* The claims this service will accept. `exp - iat` is bounded so a token
|
|
30
|
-
* minted with a far-future expiry cannot outlive the intended window.
|
|
31
|
-
*/
|
|
32
|
-
function claimsValid(payload, now) {
|
|
33
|
-
return (
|
|
34
|
-
payload.namespace === "html-render-service" &&
|
|
35
|
-
payload.purpose === "html-render" &&
|
|
36
|
-
payload.aud === "dms-frontend" &&
|
|
37
|
-
typeof payload.jti === "string" &&
|
|
38
|
-
payload.jti.length >= 16 &&
|
|
39
|
-
Number.isSafeInteger(payload.iat) &&
|
|
40
|
-
Number.isSafeInteger(payload.exp) &&
|
|
41
|
-
payload.exp > now &&
|
|
42
|
-
payload.exp - payload.iat <= RENDER_TOKEN_LIFETIME_SECONDS &&
|
|
43
|
-
payload.iat <= now
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Records a token as spent, dropping expired entries first and evicting the
|
|
49
|
-
* oldest if the map is at its cap, so a flood of tokens cannot grow it without
|
|
50
|
-
* bound.
|
|
51
|
-
*/
|
|
52
|
-
function rememberToken(payload, now) {
|
|
53
|
-
for (const [jti, expiresAt] of usedRenderTokens)
|
|
54
|
-
if (expiresAt <= now) usedRenderTokens.delete(jti);
|
|
55
|
-
while (usedRenderTokens.size >= RENDER_REPLAY_LIMIT)
|
|
56
|
-
usedRenderTokens.delete(usedRenderTokens.keys().next().value);
|
|
57
|
-
usedRenderTokens.set(payload.jti, payload.exp);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* @param {string | undefined} token the x-dms-service-token header
|
|
62
|
-
* @returns {boolean} whether the token is well-formed, signed, unexpired and
|
|
63
|
-
* not already spent. A token accepted here is immediately marked as spent.
|
|
64
|
-
*/
|
|
65
|
-
export function validRenderToken(token) {
|
|
66
|
-
const secret = process.env.DMS_HTML_RENDER_SECRET;
|
|
67
|
-
if (!token || !secret) return false;
|
|
68
|
-
const parts = token.split(".");
|
|
69
|
-
if (parts.length !== 3) return false;
|
|
70
|
-
if (!signatureValid(parts, secret)) return false;
|
|
71
|
-
try {
|
|
72
|
-
const payload = JSON.parse(Buffer.from(parts[1], "base64url"));
|
|
73
|
-
const now = Math.floor(Date.now() / 1000);
|
|
74
|
-
if (!claimsValid(payload, now)) return false;
|
|
75
|
-
if (usedRenderTokens.has(payload.jti)) return false;
|
|
76
|
-
rememberToken(payload, now);
|
|
77
|
-
return true;
|
|
78
|
-
} catch {
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
}
|