nextly 0.0.1 → 0.0.2-alpha.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/LICENSE +22 -0
- package/README.md +122 -0
- package/dist/_dts-chunks/collections-handler.d-DjgO74Wt.d.ts +20540 -0
- package/dist/_dts-chunks/config.d-DNwsDnjs.d.ts +2589 -0
- package/dist/_dts-chunks/define-component.d-BUgTHmt3.d.ts +1149 -0
- package/dist/_dts-chunks/image-processor.d-OO1PmMrv.d.ts +335 -0
- package/dist/_dts-chunks/index.d-axCAzZ7m.d.ts +17842 -0
- package/dist/_dts-chunks/media.d-DjDOZo4B.d.ts +117 -0
- package/dist/_dts-chunks/on-error.d-CHIKWNxd.d.ts +38 -0
- package/dist/_dts-chunks/storage.d-BUhQ2we_.d.ts +404 -0
- package/dist/actions/index.d.ts +239 -0
- package/dist/actions/index.mjs +281 -0
- package/dist/api/auth-state.d.ts +5 -0
- package/dist/api/auth-state.mjs +131 -0
- package/dist/api/collections-schema-detail.d.ts +56 -0
- package/dist/api/collections-schema-detail.mjs +244 -0
- package/dist/api/collections-schema-export.d.ts +56 -0
- package/dist/api/collections-schema-export.mjs +129 -0
- package/dist/api/collections-schema.d.ts +59 -0
- package/dist/api/collections-schema.mjs +207 -0
- package/dist/api/components-detail.d.ts +50 -0
- package/dist/api/components-detail.mjs +132 -0
- package/dist/api/components.d.ts +69 -0
- package/dist/api/components.mjs +144 -0
- package/dist/api/email-providers-default.d.ts +40 -0
- package/dist/api/email-providers-default.mjs +75 -0
- package/dist/api/email-providers-detail.d.ts +81 -0
- package/dist/api/email-providers-detail.mjs +109 -0
- package/dist/api/email-providers-test.d.ts +43 -0
- package/dist/api/email-providers-test.mjs +114 -0
- package/dist/api/email-providers.d.ts +69 -0
- package/dist/api/email-providers.mjs +110 -0
- package/dist/api/email-send-template.d.ts +41 -0
- package/dist/api/email-send-template.mjs +58 -0
- package/dist/api/email-send.d.ts +42 -0
- package/dist/api/email-send.mjs +58 -0
- package/dist/api/email-templates-detail.d.ts +74 -0
- package/dist/api/email-templates-detail.mjs +112 -0
- package/dist/api/email-templates-layout.d.ts +55 -0
- package/dist/api/email-templates-layout.mjs +92 -0
- package/dist/api/email-templates-preview.d.ts +48 -0
- package/dist/api/email-templates-preview.mjs +93 -0
- package/dist/api/email-templates.d.ts +61 -0
- package/dist/api/email-templates.mjs +118 -0
- package/dist/api/health.d.ts +68 -0
- package/dist/api/health.mjs +67 -0
- package/dist/api/index.d.ts +54 -0
- package/dist/api/index.mjs +16 -0
- package/dist/api/media-bulk.d.ts +74 -0
- package/dist/api/media-bulk.mjs +196 -0
- package/dist/api/media-folders.d.ts +112 -0
- package/dist/api/media-folders.mjs +187 -0
- package/dist/api/media-handlers.d.ts +102 -0
- package/dist/api/media-handlers.mjs +437 -0
- package/dist/api/media.d.ts +117 -0
- package/dist/api/media.mjs +242 -0
- package/dist/api/singles-detail.d.ts +87 -0
- package/dist/api/singles-detail.mjs +170 -0
- package/dist/api/singles-schema-detail.d.ts +54 -0
- package/dist/api/singles-schema-detail.mjs +182 -0
- package/dist/api/singles.d.ts +34 -0
- package/dist/api/singles.mjs +94 -0
- package/dist/api/storage-upload-url.d.ts +48 -0
- package/dist/api/storage-upload-url.mjs +202 -0
- package/dist/api/uploads.d.ts +109 -0
- package/dist/api/uploads.mjs +359 -0
- package/dist/auth/index.d.ts +425 -0
- package/dist/auth/index.mjs +199 -0
- package/dist/boot-apply-PQSYLDIN.mjs +7 -0
- package/dist/chunk-2OALJTK6.mjs +489 -0
- package/dist/chunk-2Q2SX2CS.mjs +365 -0
- package/dist/chunk-2TFX4ND3.mjs +13 -0
- package/dist/chunk-2TWPDSYD.mjs +87 -0
- package/dist/chunk-2W3DVD7S.mjs +647 -0
- package/dist/chunk-2ZFKXPQM.mjs +88 -0
- package/dist/chunk-3FA7FKAV.mjs +832 -0
- package/dist/chunk-3NZ2KMBL.mjs +58 -0
- package/dist/chunk-4MJLT6PZ.mjs +0 -0
- package/dist/chunk-56WO4WX7.mjs +0 -0
- package/dist/chunk-5APFUGAD.mjs +89 -0
- package/dist/chunk-5HMZ644B.mjs +108 -0
- package/dist/chunk-67GXH6PR.mjs +32 -0
- package/dist/chunk-6JNEPWRW.mjs +14368 -0
- package/dist/chunk-6NFHQIJD.mjs +45 -0
- package/dist/chunk-7P6ASYW6.mjs +9 -0
- package/dist/chunk-A3WPLSDT.mjs +1364 -0
- package/dist/chunk-AGJ6F2T3.mjs +144 -0
- package/dist/chunk-AK6Z23OX.mjs +1464 -0
- package/dist/chunk-APKKRD2G.mjs +102 -0
- package/dist/chunk-B2GV2BWH.mjs +73 -0
- package/dist/chunk-D5HQBNUB.mjs +74 -0
- package/dist/chunk-DNNG377Z.mjs +204 -0
- package/dist/chunk-DP3G27G5.mjs +135 -0
- package/dist/chunk-DV6WVX2Q.mjs +0 -0
- package/dist/chunk-DXGGXIUZ.mjs +57 -0
- package/dist/chunk-EGXBZCGC.mjs +943 -0
- package/dist/chunk-ERCNLX3V.mjs +176 -0
- package/dist/chunk-FQULBZ53.mjs +850 -0
- package/dist/chunk-G2AA4QLC.mjs +262 -0
- package/dist/chunk-GDBJ5JCU.mjs +488 -0
- package/dist/chunk-GJNSJU4S.mjs +19 -0
- package/dist/chunk-GZ6DCQKC.mjs +69 -0
- package/dist/chunk-H26B4FYG.mjs +167 -0
- package/dist/chunk-I4JMR3UR.mjs +21 -0
- package/dist/chunk-INV7QKLG.mjs +508 -0
- package/dist/chunk-IUDOC7N7.mjs +46 -0
- package/dist/chunk-IZWPRDC3.mjs +206 -0
- package/dist/chunk-KIMNCZGV.mjs +15 -0
- package/dist/chunk-L6HW2DA7.mjs +15 -0
- package/dist/chunk-LAZXX4HR.mjs +100 -0
- package/dist/chunk-LDKCUMHK.mjs +95 -0
- package/dist/chunk-LRXMECUA.mjs +0 -0
- package/dist/chunk-M52VMPGA.mjs +119 -0
- package/dist/chunk-MGUWEEI6.mjs +160 -0
- package/dist/chunk-NRUWQ5Z7.mjs +419 -0
- package/dist/chunk-NSEFNNU4.mjs +25360 -0
- package/dist/chunk-NTHVDFGO.mjs +138 -0
- package/dist/chunk-O3QHXMOX.mjs +3166 -0
- package/dist/chunk-P7NH2OSC.mjs +2605 -0
- package/dist/chunk-PKMABBB5.mjs +184 -0
- package/dist/chunk-PWS6XGJK.mjs +76 -0
- package/dist/chunk-R6JJQHFC.mjs +20 -0
- package/dist/chunk-RJLLGGPG.mjs +0 -0
- package/dist/chunk-SBACDPNX.mjs +689 -0
- package/dist/chunk-TO5AFLVQ.mjs +124 -0
- package/dist/chunk-TS7GHTG2.mjs +5436 -0
- package/dist/chunk-UJ2IMJ4W.mjs +133 -0
- package/dist/chunk-UOP63Q54.mjs +102 -0
- package/dist/chunk-UUOFWCM6.mjs +78 -0
- package/dist/chunk-V4EQTOA4.mjs +893 -0
- package/dist/chunk-VJ66NCL4.mjs +193 -0
- package/dist/chunk-VQJQHVEV.mjs +29 -0
- package/dist/chunk-VTJADRO3.mjs +141 -0
- package/dist/chunk-VWF3JO32.mjs +0 -0
- package/dist/chunk-W4MGXIRR.mjs +27 -0
- package/dist/chunk-W5KKPZT5.mjs +1204 -0
- package/dist/chunk-WD34YQ6T.mjs +381 -0
- package/dist/chunk-WZBYMYVW.mjs +14 -0
- package/dist/chunk-X23WKS3Z.mjs +50 -0
- package/dist/chunk-X7TXCYYN.mjs +6496 -0
- package/dist/chunk-XGI4EMS3.mjs +140 -0
- package/dist/chunk-XZKLBMN6.mjs +1153 -0
- package/dist/chunk-YB7INWPY.mjs +0 -0
- package/dist/chunk-YV4Y7SDL.mjs +83 -0
- package/dist/chunk-YZNBLFIW.mjs +1688 -0
- package/dist/chunk-YZZCTONM.mjs +263 -0
- package/dist/chunk-ZE6A3FYH.mjs +289 -0
- package/dist/cli/nextly.mjs +68 -0
- package/dist/cli/utils/index.d.ts +449 -0
- package/dist/cli/utils/index.mjs +49 -0
- package/dist/component-schema-service-5577KVW6.mjs +11 -0
- package/dist/config-loader-23YEMC3Z.mjs +23 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.mjs +109 -0
- package/dist/container-ORGFGYSZ.mjs +9 -0
- package/dist/database/index.d.ts +12 -0
- package/dist/database/index.mjs +40 -0
- package/dist/database/seeders/index.d.ts +93 -0
- package/dist/database/seeders/index.mjs +47 -0
- package/dist/db-sync-demote-LJGKLB3S.mjs +117 -0
- package/dist/db-sync-promote-B26VSYQF.mjs +113 -0
- package/dist/dev-reload-broadcaster-B73IQ53V.mjs +25 -0
- package/dist/dist-M2NOU37V.mjs +19 -0
- package/dist/drizzle-kit-lazy-D2M2PXR2.mjs +13 -0
- package/dist/dynamic-collection-schema-service-IEXTPIZ7.mjs +8 -0
- package/dist/errors/index.d.ts +159 -0
- package/dist/errors/index.mjs +10 -0
- package/dist/factory-IWMBKUJM.mjs +15 -0
- package/dist/first-run-QIVKWJIF.mjs +63 -0
- package/dist/fresh-push-NR67DC3R.mjs +8 -0
- package/dist/index.d.ts +4175 -0
- package/dist/index.mjs +1336 -0
- package/dist/local-plugin-PTET4NAT.mjs +7 -0
- package/dist/logger-NU46DXNY.mjs +15 -0
- package/dist/logger-YE4TC7ZN.mjs +9 -0
- package/dist/migration-journal-EP532Y4L.mjs +139 -0
- package/dist/migrations/mysql/0000_eager_sentry.sql +174 -0
- package/dist/migrations/mysql/0001_soft_giant_girl.sql +27 -0
- package/dist/migrations/mysql/0002_media_table.sql +24 -0
- package/dist/migrations/mysql/0003_dynamic_singles.sql +37 -0
- package/dist/migrations/mysql/0004_dynamic_components.sql +35 -0
- package/dist/migrations/mysql/0005_user_management_tables.sql +92 -0
- package/dist/migrations/mysql/0006_api_keys.sql +36 -0
- package/dist/migrations/mysql/0007_general_settings.sql +20 -0
- package/dist/migrations/mysql/0008_site_settings_logo_url.sql +9 -0
- package/dist/migrations/mysql/0009_activity_log.sql +30 -0
- package/dist/migrations/mysql/0010_site_settings_sidebar.sql +13 -0
- package/dist/migrations/mysql/0011_missing_tables_and_columns.sql +54 -0
- package/dist/migrations/mysql/0012_image_sizes_and_focal_point.sql +30 -0
- package/dist/migrations/mysql/0012_media_folders.sql +43 -0
- package/dist/migrations/mysql/0013_user_brute_force_protection.sql +31 -0
- package/dist/migrations/mysql/0014_email_template_attachments.sql +12 -0
- package/dist/migrations/mysql/0015_media_uploaded_by_nullable.sql +15 -0
- package/dist/migrations/mysql/20260429_000000_000_initial_journal.sql +22 -0
- package/dist/migrations/mysql/20260501_000000_journal_batch.sql +17 -0
- package/dist/migrations/mysql/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/mysql/20260504_000000_nextly_meta.sql +21 -0
- package/dist/migrations/mysql/meta/0000_snapshot.json +1005 -0
- package/dist/migrations/mysql/meta/0001_snapshot.json +1099 -0
- package/dist/migrations/mysql/meta/_journal.json +41 -0
- package/dist/migrations/postgresql/0000_misty_king_bedlam.sql +169 -0
- package/dist/migrations/postgresql/0001_perpetual_captain_marvel.sql +8 -0
- package/dist/migrations/postgresql/0002_sad_spectrum.sql +16 -0
- package/dist/migrations/postgresql/0003_hesitant_ultron.sql +17 -0
- package/dist/migrations/postgresql/0004_media_table.sql +24 -0
- package/dist/migrations/postgresql/0005_media_folders.sql +36 -0
- package/dist/migrations/postgresql/0006_dynamic_collections_update.sql +50 -0
- package/dist/migrations/postgresql/0007_dynamic_singles.sql +38 -0
- package/dist/migrations/postgresql/0008_dynamic_components.sql +37 -0
- package/dist/migrations/postgresql/0009_user_management_tables.sql +95 -0
- package/dist/migrations/postgresql/0010_api_keys.sql +34 -0
- package/dist/migrations/postgresql/0011_general_settings.sql +20 -0
- package/dist/migrations/postgresql/0012_site_settings_logo_url.sql +9 -0
- package/dist/migrations/postgresql/0013_activity_log.sql +29 -0
- package/dist/migrations/postgresql/0014_image_sizes_and_focal_point.sql +33 -0
- package/dist/migrations/postgresql/0014_site_settings_sidebar.sql +13 -0
- package/dist/migrations/postgresql/0015_user_brute_force_protection.sql +29 -0
- package/dist/migrations/postgresql/0016_email_template_attachments.sql +12 -0
- package/dist/migrations/postgresql/0017_media_uploaded_by_nullable.sql +15 -0
- package/dist/migrations/postgresql/20260429_000000_000_initial_journal.sql +24 -0
- package/dist/migrations/postgresql/20260501_000000_journal_batch.sql +17 -0
- package/dist/migrations/postgresql/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/postgresql/20260504_000000_nextly_meta.sql +22 -0
- package/dist/migrations/postgresql/meta/0000_snapshot.json +1286 -0
- package/dist/migrations/postgresql/meta/0001_snapshot.json +1407 -0
- package/dist/migrations/postgresql/meta/0002_snapshot.json +1552 -0
- package/dist/migrations/postgresql/meta/0003_snapshot.json +1695 -0
- package/dist/migrations/postgresql/meta/0010_snapshot.json +2345 -0
- package/dist/migrations/postgresql/meta/_journal.json +90 -0
- package/dist/migrations/sqlite/0000_api_keys.sql +34 -0
- package/dist/migrations/sqlite/0001_general_settings.sql +20 -0
- package/dist/migrations/sqlite/0002_site_settings_logo_url.sql +9 -0
- package/dist/migrations/sqlite/0003_activity_log.sql +29 -0
- package/dist/migrations/sqlite/0004_image_sizes_and_focal_point.sql +29 -0
- package/dist/migrations/sqlite/0004_site_settings_sidebar.sql +11 -0
- package/dist/migrations/sqlite/0005_user_brute_force_protection.sql +29 -0
- package/dist/migrations/sqlite/0006_email_template_attachments.sql +12 -0
- package/dist/migrations/sqlite/0007_media_uploaded_by_nullable.sql +111 -0
- package/dist/migrations/sqlite/20260429_000000_000_initial_journal.sql +24 -0
- package/dist/migrations/sqlite/20260501_000000_journal_batch.sql +19 -0
- package/dist/migrations/sqlite/20260501_000001_audit_log.sql +24 -0
- package/dist/migrations/sqlite/20260504_000000_nextly_meta.sql +21 -0
- package/dist/migrations/sqlite/20260505_000000_user_management_tables.sql +77 -0
- package/dist/next.d.ts +57 -0
- package/dist/next.mjs +55 -0
- package/dist/observability/index.d.ts +87 -0
- package/dist/observability/index.mjs +57 -0
- package/dist/permissions-3DZZQZMI.mjs +39 -0
- package/dist/pipeline-YOML7SWF.mjs +29 -0
- package/dist/preview-ZZTR3QGS.mjs +9 -0
- package/dist/program-PW6UB2ZC.mjs +5934 -0
- package/dist/reconcile-single-tables-7ENVXJGB.mjs +7 -0
- package/dist/register-SF6E6FVU.mjs +49 -0
- package/dist/reload-config-HWQ4G5MM.mjs +23 -0
- package/dist/resolve-single-table-name-JSOMUB3R.mjs +7 -0
- package/dist/routeHandler-UNMMJIBM.mjs +77 -0
- package/dist/runtime-schema-generator-NRA6A6Z6.mjs +8 -0
- package/dist/runtime.d.ts +120 -0
- package/dist/runtime.mjs +73 -0
- package/dist/schema-hash-FMMG6VPJ.mjs +13 -0
- package/dist/schema-registry-EQ36FZDP.mjs +7 -0
- package/dist/scripts/load-env.mjs +42 -0
- package/dist/storage/index.d.ts +566 -0
- package/dist/storage/index.mjs +45 -0
- package/dist/super-admin-G5ZK5F4T.mjs +39 -0
- package/dist/system-table-service-WGSRVEGT.mjs +17 -0
- package/dist/users-7KELGRYJ.mjs +38 -0
- package/package.json +308 -9
|
@@ -0,0 +1,1364 @@
|
|
|
1
|
+
import {
|
|
2
|
+
rateLimiter
|
|
3
|
+
} from "./chunk-DXGGXIUZ.mjs";
|
|
4
|
+
import {
|
|
5
|
+
getTrustedClientIp
|
|
6
|
+
} from "./chunk-APKKRD2G.mjs";
|
|
7
|
+
import {
|
|
8
|
+
generateRefreshToken,
|
|
9
|
+
generateRefreshTokenId,
|
|
10
|
+
getSession,
|
|
11
|
+
hashRefreshToken
|
|
12
|
+
} from "./chunk-UUOFWCM6.mjs";
|
|
13
|
+
import {
|
|
14
|
+
COOKIE_NAMES,
|
|
15
|
+
clearAccessTokenCookie,
|
|
16
|
+
getCookieOptions,
|
|
17
|
+
parseCookie,
|
|
18
|
+
readAccessTokenCookie,
|
|
19
|
+
serializeClearCookie,
|
|
20
|
+
serializeCookie,
|
|
21
|
+
setAccessTokenCookie
|
|
22
|
+
} from "./chunk-2ZFKXPQM.mjs";
|
|
23
|
+
import {
|
|
24
|
+
buildClaims,
|
|
25
|
+
signAccessToken
|
|
26
|
+
} from "./chunk-X23WKS3Z.mjs";
|
|
27
|
+
import {
|
|
28
|
+
readOrGenerateRequestId
|
|
29
|
+
} from "./chunk-67GXH6PR.mjs";
|
|
30
|
+
import {
|
|
31
|
+
getNextlyLogger
|
|
32
|
+
} from "./chunk-W4MGXIRR.mjs";
|
|
33
|
+
import {
|
|
34
|
+
EmailSchema,
|
|
35
|
+
PasswordSchema,
|
|
36
|
+
validatePasswordStrength,
|
|
37
|
+
verifyPassword
|
|
38
|
+
} from "./chunk-W5KKPZT5.mjs";
|
|
39
|
+
import {
|
|
40
|
+
respondAction,
|
|
41
|
+
respondData
|
|
42
|
+
} from "./chunk-IUDOC7N7.mjs";
|
|
43
|
+
import {
|
|
44
|
+
NextlyError
|
|
45
|
+
} from "./chunk-NRUWQ5Z7.mjs";
|
|
46
|
+
|
|
47
|
+
// src/auth/cookies/refresh-token-cookie.ts
|
|
48
|
+
function setRefreshTokenCookie(token, ttlSeconds, isProduction) {
|
|
49
|
+
const options = getCookieOptions("refreshToken", isProduction, ttlSeconds);
|
|
50
|
+
return serializeCookie(COOKIE_NAMES.refreshToken, token, options);
|
|
51
|
+
}
|
|
52
|
+
function clearRefreshTokenCookie() {
|
|
53
|
+
return serializeClearCookie(
|
|
54
|
+
COOKIE_NAMES.refreshToken,
|
|
55
|
+
"/admin/api/auth/refresh"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
function readRefreshTokenCookie(request) {
|
|
59
|
+
return parseCookie(request.headers.get("cookie"), COOKIE_NAMES.refreshToken);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/auth/csrf/csrf-cookie.ts
|
|
63
|
+
function setCsrfCookie(token, isProduction) {
|
|
64
|
+
const options = getCookieOptions("csrf", isProduction);
|
|
65
|
+
return serializeCookie(COOKIE_NAMES.csrf, token, options);
|
|
66
|
+
}
|
|
67
|
+
function clearCsrfCookie() {
|
|
68
|
+
return serializeClearCookie(COOKIE_NAMES.csrf, "/admin");
|
|
69
|
+
}
|
|
70
|
+
function readCsrfCookie(request) {
|
|
71
|
+
return parseCookie(request.headers.get("cookie"), COOKIE_NAMES.csrf);
|
|
72
|
+
}
|
|
73
|
+
function readCsrfFromRequest(body, request) {
|
|
74
|
+
const headerToken = request.headers.get("x-csrf-token");
|
|
75
|
+
if (headerToken) return headerToken;
|
|
76
|
+
if (body && typeof body.csrfToken === "string") {
|
|
77
|
+
return body.csrfToken;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/auth/csrf/validate.ts
|
|
83
|
+
import { timingSafeEqual } from "crypto";
|
|
84
|
+
function csrfTokensMatch(cookieToken, requestToken) {
|
|
85
|
+
if (!cookieToken || !requestToken) return false;
|
|
86
|
+
if (cookieToken.length !== requestToken.length) return false;
|
|
87
|
+
try {
|
|
88
|
+
const a = Buffer.from(cookieToken, "utf-8");
|
|
89
|
+
const b = Buffer.from(requestToken, "utf-8");
|
|
90
|
+
return timingSafeEqual(a, b);
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function validateOrigin(request, allowedOrigins) {
|
|
96
|
+
const origin = request.headers.get("origin");
|
|
97
|
+
const referer = request.headers.get("referer");
|
|
98
|
+
const requestOrigin = origin || (referer ? new URL(referer).origin : null);
|
|
99
|
+
if (!requestOrigin) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
const requestUrl = new URL(request.url);
|
|
103
|
+
const selfOrigin = requestUrl.origin;
|
|
104
|
+
const allAllowed = [selfOrigin, ...allowedOrigins];
|
|
105
|
+
return allAllowed.some(
|
|
106
|
+
(allowed) => requestOrigin.toLowerCase() === allowed.toLowerCase()
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
function validateCsrf(request, cookieToken, requestToken, allowedOrigins = []) {
|
|
110
|
+
if (!cookieToken || !requestToken) {
|
|
111
|
+
return { valid: false, error: "Missing CSRF token" };
|
|
112
|
+
}
|
|
113
|
+
if (!csrfTokensMatch(cookieToken, requestToken)) {
|
|
114
|
+
return { valid: false, error: "Invalid CSRF token" };
|
|
115
|
+
}
|
|
116
|
+
if (!validateOrigin(request, allowedOrigins)) {
|
|
117
|
+
return { valid: false, error: "Invalid request origin" };
|
|
118
|
+
}
|
|
119
|
+
return { valid: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/auth/handlers/handler-utils.ts
|
|
123
|
+
function jsonResponse(status, body, headers) {
|
|
124
|
+
return new Response(JSON.stringify(body), {
|
|
125
|
+
status,
|
|
126
|
+
headers: {
|
|
127
|
+
"Content-Type": "application/json",
|
|
128
|
+
...headers
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function stallResponse(startTime, stallMs) {
|
|
133
|
+
const elapsed = Date.now() - startTime;
|
|
134
|
+
if (elapsed < stallMs) {
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, stallMs - elapsed));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function buildCookieHeaders(cookies, extra) {
|
|
139
|
+
const headers = new Headers({
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
...extra
|
|
142
|
+
});
|
|
143
|
+
for (const cookie of cookies) {
|
|
144
|
+
headers.append("Set-Cookie", cookie);
|
|
145
|
+
}
|
|
146
|
+
return headers;
|
|
147
|
+
}
|
|
148
|
+
async function parseJsonBody(request) {
|
|
149
|
+
try {
|
|
150
|
+
return await request.json();
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/auth/handlers/change-password.ts
|
|
157
|
+
async function handleChangePassword(request, deps) {
|
|
158
|
+
const sessionResult = await getSession(request, deps.secret);
|
|
159
|
+
if (!sessionResult.authenticated) {
|
|
160
|
+
return jsonResponse(401, {
|
|
161
|
+
error: { code: "AUTH_REQUIRED", message: "Authentication required" }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const body = await request.json();
|
|
165
|
+
const csrfCookie = readCsrfCookie(request);
|
|
166
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
167
|
+
const csrfResult = validateCsrf(
|
|
168
|
+
request,
|
|
169
|
+
csrfCookie,
|
|
170
|
+
csrfToken,
|
|
171
|
+
deps.allowedOrigins
|
|
172
|
+
);
|
|
173
|
+
if (!csrfResult.valid) {
|
|
174
|
+
return jsonResponse(403, {
|
|
175
|
+
error: { code: "CSRF_FAILED", message: csrfResult.error }
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
const { currentPassword, newPassword } = body;
|
|
179
|
+
if (!currentPassword || !newPassword) {
|
|
180
|
+
return jsonResponse(400, {
|
|
181
|
+
error: {
|
|
182
|
+
code: "VALIDATION_ERROR",
|
|
183
|
+
message: "Current password and new password are required"
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
const result = await deps.changePassword(
|
|
188
|
+
sessionResult.user.id,
|
|
189
|
+
currentPassword,
|
|
190
|
+
newPassword
|
|
191
|
+
);
|
|
192
|
+
if (!result.success) {
|
|
193
|
+
return jsonResponse(400, {
|
|
194
|
+
error: { code: "PASSWORD_CHANGE_FAILED", message: result.error }
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
await deps.deleteAllRefreshTokensForUser(sessionResult.user.id);
|
|
198
|
+
await deps.auditLog.write({
|
|
199
|
+
kind: "password-changed",
|
|
200
|
+
actorUserId: sessionResult.user.id,
|
|
201
|
+
targetUserId: sessionResult.user.id,
|
|
202
|
+
ipAddress: getTrustedClientIp(request, {
|
|
203
|
+
trustProxy: deps.trustProxy,
|
|
204
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
205
|
+
}),
|
|
206
|
+
userAgent: request.headers.get("user-agent")
|
|
207
|
+
});
|
|
208
|
+
const clearCookies = [clearAccessTokenCookie(), clearRefreshTokenCookie()];
|
|
209
|
+
return respondAction(
|
|
210
|
+
"Password changed.",
|
|
211
|
+
{},
|
|
212
|
+
{ status: 200, headers: buildCookieHeaders(clearCookies) }
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/auth/csrf/generate.ts
|
|
217
|
+
import { randomBytes } from "crypto";
|
|
218
|
+
function generateCsrfToken() {
|
|
219
|
+
return randomBytes(32).toString("hex");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// src/auth/handlers/csrf.ts
|
|
223
|
+
async function handleCsrf(_request, deps) {
|
|
224
|
+
const token = generateCsrfToken();
|
|
225
|
+
const cookie = setCsrfCookie(token, deps.isProduction);
|
|
226
|
+
const headers = new Headers();
|
|
227
|
+
headers.append("Set-Cookie", cookie);
|
|
228
|
+
return respondData({ token }, { status: 200, headers });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/auth/handlers/forgot-password.ts
|
|
232
|
+
var SILENT_MESSAGE = "If an account exists for this email, a password reset link has been sent.";
|
|
233
|
+
function sanitizeRedirectPath(raw) {
|
|
234
|
+
if (typeof raw !== "string" || raw.length === 0) return void 0;
|
|
235
|
+
if (raw.length > 2048 || raw.includes("\0")) {
|
|
236
|
+
console.warn(
|
|
237
|
+
`[nextly/auth] Rejected forgot-password redirectPath: oversized or contained null byte`
|
|
238
|
+
);
|
|
239
|
+
return void 0;
|
|
240
|
+
}
|
|
241
|
+
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
|
242
|
+
if (raw === "/admin" || raw.startsWith("/admin/") || raw.startsWith("/admin?")) {
|
|
243
|
+
return raw;
|
|
244
|
+
}
|
|
245
|
+
console.warn(
|
|
246
|
+
`[nextly/auth] Rejected forgot-password redirectPath outside /admin: ${raw}`
|
|
247
|
+
);
|
|
248
|
+
return void 0;
|
|
249
|
+
}
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = new URL(raw);
|
|
253
|
+
} catch {
|
|
254
|
+
console.warn(
|
|
255
|
+
`[nextly/auth] Rejected forgot-password redirectPath: unparseable URL: ${raw}`
|
|
256
|
+
);
|
|
257
|
+
return void 0;
|
|
258
|
+
}
|
|
259
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
260
|
+
console.warn(
|
|
261
|
+
`[nextly/auth] Rejected forgot-password redirectPath: bad protocol ${parsed.protocol}`
|
|
262
|
+
);
|
|
263
|
+
return void 0;
|
|
264
|
+
}
|
|
265
|
+
const allowed = (process.env.ALLOWED_REDIRECT_HOSTS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
266
|
+
const host = parsed.host.toLowerCase();
|
|
267
|
+
if (allowed.includes(host)) {
|
|
268
|
+
return raw;
|
|
269
|
+
}
|
|
270
|
+
console.warn(
|
|
271
|
+
`[nextly/auth] Rejected forgot-password redirectPath: host ${host} not in ALLOWED_REDIRECT_HOSTS`
|
|
272
|
+
);
|
|
273
|
+
return void 0;
|
|
274
|
+
}
|
|
275
|
+
function buildForgotErrorResponse(err, requestId) {
|
|
276
|
+
return new Response(
|
|
277
|
+
JSON.stringify({ error: err.toResponseJSON(requestId) }),
|
|
278
|
+
{
|
|
279
|
+
status: err.statusCode,
|
|
280
|
+
headers: {
|
|
281
|
+
"content-type": "application/problem+json",
|
|
282
|
+
"x-request-id": requestId
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
async function handleForgotPassword(request, deps) {
|
|
288
|
+
const startTime = Date.now();
|
|
289
|
+
const requestId = readOrGenerateRequestId(request);
|
|
290
|
+
try {
|
|
291
|
+
const body = await request.json();
|
|
292
|
+
const csrfCookie = readCsrfCookie(request);
|
|
293
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
294
|
+
const csrfResult = validateCsrf(
|
|
295
|
+
request,
|
|
296
|
+
csrfCookie,
|
|
297
|
+
csrfToken,
|
|
298
|
+
deps.allowedOrigins
|
|
299
|
+
);
|
|
300
|
+
if (!csrfResult.valid) {
|
|
301
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
302
|
+
return jsonResponse(
|
|
303
|
+
403,
|
|
304
|
+
{ error: { code: "CSRF_FAILED", message: csrfResult.error } },
|
|
305
|
+
{ "x-request-id": requestId }
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
const { email, redirectPath } = body;
|
|
309
|
+
if (!email) {
|
|
310
|
+
throw NextlyError.validation({
|
|
311
|
+
errors: [{ path: "email", code: "REQUIRED", message: "Required." }]
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const safeRedirectPath = sanitizeRedirectPath(redirectPath);
|
|
315
|
+
try {
|
|
316
|
+
await deps.generatePasswordResetToken(email, safeRedirectPath);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
if (NextlyError.is(err) && (err.code === "NOT_FOUND" || err.code === "AUTH_INVALID_CREDENTIALS" || err.code === "VALIDATION_ERROR")) {
|
|
319
|
+
} else {
|
|
320
|
+
throw err;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
324
|
+
return respondAction(
|
|
325
|
+
SILENT_MESSAGE,
|
|
326
|
+
{},
|
|
327
|
+
{ status: 200, headers: { "x-request-id": requestId } }
|
|
328
|
+
);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
331
|
+
if (NextlyError.is(err)) {
|
|
332
|
+
return buildForgotErrorResponse(err, requestId);
|
|
333
|
+
}
|
|
334
|
+
return buildForgotErrorResponse(
|
|
335
|
+
NextlyError.internal({ cause: err }),
|
|
336
|
+
requestId
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/auth/credentials/verify-credentials.ts
|
|
342
|
+
var DUMMY_HASH = "$2b$12$ML1pr5W9k0ODLs2GFo9gruB/VcQfuby0nAeFo959eFXl0u1ZUmbb6";
|
|
343
|
+
async function verifyCredentials(input, deps) {
|
|
344
|
+
const user = await deps.findUserByEmail(input.email);
|
|
345
|
+
const passwordOk = user ? await verifyPassword(input.password, user.passwordHash) : await verifyPassword(input.password, DUMMY_HASH);
|
|
346
|
+
if (!user || !passwordOk) {
|
|
347
|
+
if (user) {
|
|
348
|
+
const newAttempts = user.failedLoginAttempts + 1;
|
|
349
|
+
if (newAttempts >= deps.maxLoginAttempts) {
|
|
350
|
+
const lockedUntil = new Date(
|
|
351
|
+
Date.now() + deps.lockoutDurationSeconds * 1e3
|
|
352
|
+
);
|
|
353
|
+
await deps.lockAccount(user.id, lockedUntil);
|
|
354
|
+
} else {
|
|
355
|
+
await deps.incrementFailedAttempts(user.id);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
throw NextlyError.invalidCredentials({
|
|
359
|
+
logContext: {
|
|
360
|
+
email: input.email,
|
|
361
|
+
reason: !user ? "user-not-found" : "password-mismatch"
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (user.lockedUntil && user.lockedUntil > /* @__PURE__ */ new Date()) {
|
|
366
|
+
throw NextlyError.invalidCredentials({
|
|
367
|
+
logContext: {
|
|
368
|
+
userId: user.id,
|
|
369
|
+
reason: "locked",
|
|
370
|
+
lockedUntil: user.lockedUntil
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
if (deps.requireEmailVerification && !user.emailVerified) {
|
|
375
|
+
throw NextlyError.invalidCredentials({
|
|
376
|
+
logContext: { userId: user.id, reason: "unverified" }
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (!user.isActive) {
|
|
380
|
+
throw NextlyError.invalidCredentials({
|
|
381
|
+
logContext: { userId: user.id, reason: "inactive" }
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
if (user.failedLoginAttempts > 0) {
|
|
385
|
+
await deps.resetFailedAttempts(user.id);
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
id: user.id,
|
|
389
|
+
email: user.email,
|
|
390
|
+
name: user.name,
|
|
391
|
+
image: user.image,
|
|
392
|
+
emailVerified: user.emailVerified,
|
|
393
|
+
isActive: user.isActive
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/auth/handlers/login.ts
|
|
398
|
+
function buildLoginErrorResponse(err, requestId) {
|
|
399
|
+
return new Response(
|
|
400
|
+
JSON.stringify({ error: err.toResponseJSON(requestId) }),
|
|
401
|
+
{
|
|
402
|
+
status: err.statusCode,
|
|
403
|
+
headers: {
|
|
404
|
+
"content-type": "application/problem+json",
|
|
405
|
+
"x-request-id": requestId
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
async function handleLogin(request, deps) {
|
|
411
|
+
const startTime = Date.now();
|
|
412
|
+
const requestId = readOrGenerateRequestId(request);
|
|
413
|
+
try {
|
|
414
|
+
const body = await request.json();
|
|
415
|
+
const csrfCookie = readCsrfCookie(request);
|
|
416
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
417
|
+
const csrfResult = validateCsrf(
|
|
418
|
+
request,
|
|
419
|
+
csrfCookie,
|
|
420
|
+
csrfToken,
|
|
421
|
+
deps.allowedOrigins
|
|
422
|
+
);
|
|
423
|
+
if (!csrfResult.valid) {
|
|
424
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
425
|
+
return jsonResponse(
|
|
426
|
+
403,
|
|
427
|
+
{
|
|
428
|
+
error: { code: "CSRF_FAILED", message: csrfResult.error }
|
|
429
|
+
},
|
|
430
|
+
{ "x-request-id": requestId }
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
const verifiedUser = await verifyCredentials(
|
|
434
|
+
{ email: body.email, password: body.password },
|
|
435
|
+
{
|
|
436
|
+
findUserByEmail: deps.findUserByEmail,
|
|
437
|
+
incrementFailedAttempts: deps.incrementFailedAttempts,
|
|
438
|
+
lockAccount: deps.lockAccount,
|
|
439
|
+
resetFailedAttempts: deps.resetFailedAttempts,
|
|
440
|
+
maxLoginAttempts: deps.maxLoginAttempts,
|
|
441
|
+
lockoutDurationSeconds: deps.lockoutDurationSeconds,
|
|
442
|
+
requireEmailVerification: deps.requireEmailVerification
|
|
443
|
+
}
|
|
444
|
+
);
|
|
445
|
+
const [roleIds, customFields] = await Promise.all([
|
|
446
|
+
deps.fetchRoleIds(verifiedUser.id),
|
|
447
|
+
deps.fetchCustomFields(verifiedUser.id)
|
|
448
|
+
]);
|
|
449
|
+
const claims = buildClaims({
|
|
450
|
+
userId: verifiedUser.id,
|
|
451
|
+
email: verifiedUser.email,
|
|
452
|
+
name: verifiedUser.name,
|
|
453
|
+
image: verifiedUser.image,
|
|
454
|
+
roleIds,
|
|
455
|
+
customFields
|
|
456
|
+
});
|
|
457
|
+
const accessToken = await signAccessToken(
|
|
458
|
+
claims,
|
|
459
|
+
deps.secret,
|
|
460
|
+
deps.accessTokenTTL
|
|
461
|
+
);
|
|
462
|
+
const rawRefreshToken = generateRefreshToken();
|
|
463
|
+
const refreshTokenHash = hashRefreshToken(rawRefreshToken);
|
|
464
|
+
await deps.storeRefreshToken({
|
|
465
|
+
id: generateRefreshTokenId(),
|
|
466
|
+
userId: verifiedUser.id,
|
|
467
|
+
tokenHash: refreshTokenHash,
|
|
468
|
+
userAgent: request.headers.get("user-agent"),
|
|
469
|
+
ipAddress: getTrustedClientIp(request, {
|
|
470
|
+
trustProxy: deps.trustProxy,
|
|
471
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
472
|
+
}),
|
|
473
|
+
expiresAt: new Date(Date.now() + deps.refreshTokenTTL * 1e3)
|
|
474
|
+
});
|
|
475
|
+
const cookies = [
|
|
476
|
+
setAccessTokenCookie(
|
|
477
|
+
accessToken,
|
|
478
|
+
deps.refreshTokenTTL,
|
|
479
|
+
deps.isProduction
|
|
480
|
+
),
|
|
481
|
+
setRefreshTokenCookie(
|
|
482
|
+
rawRefreshToken,
|
|
483
|
+
deps.refreshTokenTTL,
|
|
484
|
+
deps.isProduction
|
|
485
|
+
)
|
|
486
|
+
];
|
|
487
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
488
|
+
return respondAction(
|
|
489
|
+
"Logged in.",
|
|
490
|
+
{
|
|
491
|
+
user: {
|
|
492
|
+
id: verifiedUser.id,
|
|
493
|
+
email: verifiedUser.email,
|
|
494
|
+
name: verifiedUser.name,
|
|
495
|
+
image: verifiedUser.image,
|
|
496
|
+
roleIds
|
|
497
|
+
},
|
|
498
|
+
accessToken,
|
|
499
|
+
refreshToken: rawRefreshToken,
|
|
500
|
+
// `expiresAt` reflects the access-token JWT exp claim (the
|
|
501
|
+
// authoritative expiration server-side), not the cookie max-age.
|
|
502
|
+
// signAccessToken uses deps.accessTokenTTL for that.
|
|
503
|
+
expiresAt: new Date(
|
|
504
|
+
Date.now() + deps.accessTokenTTL * 1e3
|
|
505
|
+
).toISOString()
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
status: 200,
|
|
509
|
+
headers: buildCookieHeaders(cookies, { "x-request-id": requestId })
|
|
510
|
+
}
|
|
511
|
+
);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
514
|
+
await deps.auditLog.write({
|
|
515
|
+
kind: "login-failed",
|
|
516
|
+
ipAddress: getTrustedClientIp(request, {
|
|
517
|
+
trustProxy: deps.trustProxy,
|
|
518
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
519
|
+
}),
|
|
520
|
+
userAgent: request.headers.get("user-agent"),
|
|
521
|
+
metadata: NextlyError.is(err) ? { code: err.code, ...err.logContext ?? {} } : { code: "INTERNAL_ERROR" }
|
|
522
|
+
});
|
|
523
|
+
if (NextlyError.is(err)) {
|
|
524
|
+
return buildLoginErrorResponse(err, requestId);
|
|
525
|
+
}
|
|
526
|
+
return buildLoginErrorResponse(
|
|
527
|
+
NextlyError.internal({ cause: err }),
|
|
528
|
+
requestId
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// src/auth/handlers/logout.ts
|
|
534
|
+
async function handleLogout(request, deps) {
|
|
535
|
+
const body = await parseJsonBody(request);
|
|
536
|
+
const csrfCookie = readCsrfCookie(request);
|
|
537
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
538
|
+
const csrfResult = validateCsrf(
|
|
539
|
+
request,
|
|
540
|
+
csrfCookie,
|
|
541
|
+
csrfToken,
|
|
542
|
+
deps.allowedOrigins
|
|
543
|
+
);
|
|
544
|
+
if (!csrfResult.valid) {
|
|
545
|
+
return jsonResponse(403, {
|
|
546
|
+
error: { code: "CSRF_FAILED", message: csrfResult.error }
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
const refreshToken = readRefreshTokenCookie(request);
|
|
550
|
+
if (refreshToken) {
|
|
551
|
+
const tokenHash = hashRefreshToken(refreshToken);
|
|
552
|
+
await deps.deleteRefreshTokenByHash(tokenHash);
|
|
553
|
+
}
|
|
554
|
+
const clearCookies = [
|
|
555
|
+
clearAccessTokenCookie(),
|
|
556
|
+
clearRefreshTokenCookie(),
|
|
557
|
+
clearCsrfCookie()
|
|
558
|
+
];
|
|
559
|
+
return respondAction(
|
|
560
|
+
"Logged out.",
|
|
561
|
+
{},
|
|
562
|
+
{ status: 200, headers: buildCookieHeaders(clearCookies) }
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/auth/session/refresh-binding.ts
|
|
567
|
+
var IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
|
568
|
+
var IPV6_CHAR_RE = /^[0-9a-fA-F:.]+$/;
|
|
569
|
+
function classifyIp(addr) {
|
|
570
|
+
if (IPV4_RE.test(addr)) return 4;
|
|
571
|
+
if (addr.includes(":") && IPV6_CHAR_RE.test(addr) && addr.length <= 45) {
|
|
572
|
+
return 6;
|
|
573
|
+
}
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
function ipv4ToInt(ip) {
|
|
577
|
+
const parts = ip.split(".");
|
|
578
|
+
if (parts.length !== 4) return null;
|
|
579
|
+
let acc = 0;
|
|
580
|
+
for (const p of parts) {
|
|
581
|
+
const n = Number(p);
|
|
582
|
+
if (!Number.isInteger(n) || n < 0 || n > 255) return null;
|
|
583
|
+
acc = acc << 8 | n;
|
|
584
|
+
}
|
|
585
|
+
return acc >>> 0;
|
|
586
|
+
}
|
|
587
|
+
function sameIpv4Prefix(a, b, prefix) {
|
|
588
|
+
const ai = ipv4ToInt(a);
|
|
589
|
+
const bi = ipv4ToInt(b);
|
|
590
|
+
if (ai === null || bi === null) return false;
|
|
591
|
+
const mask = prefix === 0 ? 0 : -1 >>> 32 - prefix << 32 - prefix;
|
|
592
|
+
return (ai & mask) >>> 0 === (bi & mask) >>> 0;
|
|
593
|
+
}
|
|
594
|
+
function ipv6PrefixHex(addr, prefixBits) {
|
|
595
|
+
if (prefixBits % 4 !== 0) return null;
|
|
596
|
+
const hexCount = prefixBits / 4;
|
|
597
|
+
let work = addr.toLowerCase();
|
|
598
|
+
const dottedMatch = work.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/);
|
|
599
|
+
if (dottedMatch) {
|
|
600
|
+
const v4 = ipv4ToInt(dottedMatch[2]);
|
|
601
|
+
if (v4 === null) return null;
|
|
602
|
+
const high = (v4 >>> 16 & 65535).toString(16);
|
|
603
|
+
const low = (v4 & 65535).toString(16);
|
|
604
|
+
work = `${dottedMatch[1]}${high}:${low}`;
|
|
605
|
+
}
|
|
606
|
+
const doubleColon = work.split("::");
|
|
607
|
+
if (doubleColon.length > 2) return null;
|
|
608
|
+
let groups;
|
|
609
|
+
if (doubleColon.length === 2) {
|
|
610
|
+
const left = doubleColon[0] === "" ? [] : doubleColon[0].split(":");
|
|
611
|
+
const right = doubleColon[1] === "" ? [] : doubleColon[1].split(":");
|
|
612
|
+
if (left.length + right.length > 8) return null;
|
|
613
|
+
const fill = new Array(8 - left.length - right.length).fill("0");
|
|
614
|
+
groups = [...left, ...fill, ...right];
|
|
615
|
+
} else {
|
|
616
|
+
groups = work.split(":");
|
|
617
|
+
if (groups.length !== 8) return null;
|
|
618
|
+
}
|
|
619
|
+
let out = "";
|
|
620
|
+
for (const g of groups) {
|
|
621
|
+
if (!/^[0-9a-f]{0,4}$/.test(g)) return null;
|
|
622
|
+
out += g.padStart(4, "0");
|
|
623
|
+
if (out.length >= hexCount) break;
|
|
624
|
+
}
|
|
625
|
+
return out.slice(0, hexCount);
|
|
626
|
+
}
|
|
627
|
+
function sameIpv6Prefix(a, b, prefix) {
|
|
628
|
+
const ah = ipv6PrefixHex(a, prefix);
|
|
629
|
+
const bh = ipv6PrefixHex(b, prefix);
|
|
630
|
+
if (ah === null || bh === null) return false;
|
|
631
|
+
return ah === bh;
|
|
632
|
+
}
|
|
633
|
+
function evaluateRefreshBinding(input) {
|
|
634
|
+
const ipResult = compareIps(input.storedIp, input.currentIp);
|
|
635
|
+
if (ipResult !== null) {
|
|
636
|
+
return { kind: "hard", reason: ipResult };
|
|
637
|
+
}
|
|
638
|
+
if (input.storedUserAgent !== null && input.currentUserAgent !== null && input.storedUserAgent !== input.currentUserAgent) {
|
|
639
|
+
return { kind: "soft", reason: "ua-mismatch" };
|
|
640
|
+
}
|
|
641
|
+
return { kind: "ok" };
|
|
642
|
+
}
|
|
643
|
+
function compareIps(stored, current) {
|
|
644
|
+
if (stored === null || current === null) return null;
|
|
645
|
+
if (stored === current) return null;
|
|
646
|
+
const sf = classifyIp(stored);
|
|
647
|
+
const cf = classifyIp(current);
|
|
648
|
+
if (sf === 0 || cf === 0) return null;
|
|
649
|
+
if (sf !== cf) return "ip-family-mismatch";
|
|
650
|
+
if (sf === 4) {
|
|
651
|
+
return sameIpv4Prefix(stored, current, 24) ? null : "ipv4-prefix-mismatch";
|
|
652
|
+
}
|
|
653
|
+
return sameIpv6Prefix(stored, current, 48) ? null : "ipv6-prefix-mismatch";
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// src/auth/handlers/refresh.ts
|
|
657
|
+
async function handleRefresh(request, deps) {
|
|
658
|
+
const rawToken = readRefreshTokenCookie(request);
|
|
659
|
+
if (!rawToken) {
|
|
660
|
+
return clearAndDeny("No refresh token");
|
|
661
|
+
}
|
|
662
|
+
const tokenHash = hashRefreshToken(rawToken);
|
|
663
|
+
const tokenRecord = await deps.findRefreshTokenByHash(tokenHash);
|
|
664
|
+
if (!tokenRecord) {
|
|
665
|
+
return clearAndDeny("Invalid refresh token");
|
|
666
|
+
}
|
|
667
|
+
if (tokenRecord.expiresAt < /* @__PURE__ */ new Date()) {
|
|
668
|
+
await deps.deleteRefreshToken(tokenRecord.id);
|
|
669
|
+
return clearAndDeny("Refresh token expired");
|
|
670
|
+
}
|
|
671
|
+
const currentUserAgent = request.headers.get("user-agent");
|
|
672
|
+
const currentIp = getTrustedClientIp(request, {
|
|
673
|
+
trustProxy: deps.trustProxy,
|
|
674
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
675
|
+
});
|
|
676
|
+
const binding = evaluateRefreshBinding({
|
|
677
|
+
storedUserAgent: tokenRecord.userAgent,
|
|
678
|
+
currentUserAgent,
|
|
679
|
+
storedIp: tokenRecord.ipAddress,
|
|
680
|
+
currentIp
|
|
681
|
+
});
|
|
682
|
+
if (binding.kind === "hard") {
|
|
683
|
+
await deps.deleteRefreshToken(tokenRecord.id);
|
|
684
|
+
await deps.deleteAllRefreshTokensForUser(tokenRecord.userId);
|
|
685
|
+
getNextlyLogger().warn({
|
|
686
|
+
kind: "refresh-binding-hard-fail",
|
|
687
|
+
reason: binding.reason,
|
|
688
|
+
tokenId: tokenRecord.id,
|
|
689
|
+
userId: tokenRecord.userId
|
|
690
|
+
});
|
|
691
|
+
return clearAndDeny("Session binding mismatch");
|
|
692
|
+
}
|
|
693
|
+
if (binding.kind === "soft") {
|
|
694
|
+
getNextlyLogger().warn({
|
|
695
|
+
kind: "refresh-binding-soft-warn",
|
|
696
|
+
reason: binding.reason,
|
|
697
|
+
tokenId: tokenRecord.id,
|
|
698
|
+
userId: tokenRecord.userId
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
await deps.deleteRefreshToken(tokenRecord.id);
|
|
702
|
+
const user = await deps.findUserById(tokenRecord.userId);
|
|
703
|
+
if (!user || !user.isActive) {
|
|
704
|
+
return clearAndDeny("User not found or inactive");
|
|
705
|
+
}
|
|
706
|
+
const [roleIds, customFields] = await Promise.all([
|
|
707
|
+
deps.fetchRoleIds(user.id),
|
|
708
|
+
deps.fetchCustomFields(user.id)
|
|
709
|
+
]);
|
|
710
|
+
const claims = buildClaims({
|
|
711
|
+
userId: user.id,
|
|
712
|
+
email: user.email,
|
|
713
|
+
name: user.name,
|
|
714
|
+
image: user.image,
|
|
715
|
+
roleIds,
|
|
716
|
+
customFields
|
|
717
|
+
});
|
|
718
|
+
const accessToken = await signAccessToken(
|
|
719
|
+
claims,
|
|
720
|
+
deps.secret,
|
|
721
|
+
deps.accessTokenTTL
|
|
722
|
+
);
|
|
723
|
+
const newRawToken = generateRefreshToken();
|
|
724
|
+
const newTokenHash = hashRefreshToken(newRawToken);
|
|
725
|
+
await deps.storeRefreshToken({
|
|
726
|
+
id: generateRefreshTokenId(),
|
|
727
|
+
userId: user.id,
|
|
728
|
+
tokenHash: newTokenHash,
|
|
729
|
+
userAgent: request.headers.get("user-agent"),
|
|
730
|
+
ipAddress: getTrustedClientIp(request, {
|
|
731
|
+
trustProxy: deps.trustProxy,
|
|
732
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
733
|
+
}),
|
|
734
|
+
expiresAt: new Date(Date.now() + deps.refreshTokenTTL * 1e3)
|
|
735
|
+
});
|
|
736
|
+
const cookies = [
|
|
737
|
+
setAccessTokenCookie(accessToken, deps.refreshTokenTTL, deps.isProduction),
|
|
738
|
+
setRefreshTokenCookie(newRawToken, deps.refreshTokenTTL, deps.isProduction)
|
|
739
|
+
];
|
|
740
|
+
return respondData(
|
|
741
|
+
{
|
|
742
|
+
user: {
|
|
743
|
+
id: user.id,
|
|
744
|
+
email: user.email,
|
|
745
|
+
name: user.name,
|
|
746
|
+
image: user.image,
|
|
747
|
+
roleIds
|
|
748
|
+
},
|
|
749
|
+
accessToken,
|
|
750
|
+
refreshToken: newRawToken,
|
|
751
|
+
// Authoritative server-side exp lives on the JWT itself (accessTokenTTL).
|
|
752
|
+
expiresAt: new Date(
|
|
753
|
+
Date.now() + deps.accessTokenTTL * 1e3
|
|
754
|
+
).toISOString()
|
|
755
|
+
},
|
|
756
|
+
{ status: 200, headers: buildCookieHeaders(cookies) }
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
function clearAndDeny(message) {
|
|
760
|
+
const clearCookies = [
|
|
761
|
+
clearAccessTokenCookie(),
|
|
762
|
+
clearRefreshTokenCookie(),
|
|
763
|
+
clearCsrfCookie()
|
|
764
|
+
];
|
|
765
|
+
return new Response(
|
|
766
|
+
JSON.stringify({
|
|
767
|
+
error: { code: "REFRESH_FAILED", message }
|
|
768
|
+
}),
|
|
769
|
+
{ status: 401, headers: buildCookieHeaders(clearCookies) }
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/auth/handlers/register.ts
|
|
774
|
+
import { z } from "zod";
|
|
775
|
+
var RegisterPayloadSchema = z.object({
|
|
776
|
+
email: EmailSchema,
|
|
777
|
+
password: PasswordSchema,
|
|
778
|
+
name: z.string().trim().min(1, "Name is required.").max(100, "Name must be 100 characters or less.")
|
|
779
|
+
});
|
|
780
|
+
var SILENT_SUCCESS_MESSAGE = "If this email is available, we've sent a confirmation link.";
|
|
781
|
+
function zodIssueToCode(issue) {
|
|
782
|
+
switch (issue.code) {
|
|
783
|
+
case "invalid_type":
|
|
784
|
+
return "REQUIRED";
|
|
785
|
+
case "too_small":
|
|
786
|
+
return issue.minimum === 1 ? "REQUIRED" : "TOO_SHORT";
|
|
787
|
+
case "too_big":
|
|
788
|
+
return "TOO_LONG";
|
|
789
|
+
case "invalid_format":
|
|
790
|
+
return "INVALID_FORMAT";
|
|
791
|
+
default:
|
|
792
|
+
return "INVALID";
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
function buildRegisterErrorResponse(err, requestId) {
|
|
796
|
+
return new Response(
|
|
797
|
+
JSON.stringify({ error: err.toResponseJSON(requestId) }),
|
|
798
|
+
{
|
|
799
|
+
status: err.statusCode,
|
|
800
|
+
headers: {
|
|
801
|
+
"content-type": "application/problem+json",
|
|
802
|
+
"x-request-id": requestId
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
async function handleRegister(request, deps) {
|
|
808
|
+
const startTime = Date.now();
|
|
809
|
+
const requestId = readOrGenerateRequestId(request);
|
|
810
|
+
try {
|
|
811
|
+
const body = await request.json();
|
|
812
|
+
const csrfCookie = readCsrfCookie(request);
|
|
813
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
814
|
+
const csrfResult = validateCsrf(
|
|
815
|
+
request,
|
|
816
|
+
csrfCookie,
|
|
817
|
+
csrfToken,
|
|
818
|
+
deps.allowedOrigins
|
|
819
|
+
);
|
|
820
|
+
if (!csrfResult.valid) {
|
|
821
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
822
|
+
return jsonResponse(
|
|
823
|
+
403,
|
|
824
|
+
{ error: { code: "CSRF_FAILED", message: csrfResult.error } },
|
|
825
|
+
{ "x-request-id": requestId }
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
const parsed = RegisterPayloadSchema.safeParse(body);
|
|
829
|
+
if (!parsed.success) {
|
|
830
|
+
throw NextlyError.validation({
|
|
831
|
+
errors: parsed.error.issues.map((issue) => ({
|
|
832
|
+
path: issue.path.join(".") || "root",
|
|
833
|
+
code: zodIssueToCode(issue),
|
|
834
|
+
message: issue.message.endsWith(".") ? issue.message : `${issue.message}.`
|
|
835
|
+
}))
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
const { email, password, name } = parsed.data;
|
|
839
|
+
try {
|
|
840
|
+
const user = await deps.registerUser({ email, password, name });
|
|
841
|
+
if (deps.revealRegistrationConflict) {
|
|
842
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
843
|
+
return respondAction(
|
|
844
|
+
"Account created.",
|
|
845
|
+
{ user: { id: user.id, email: user.email, name: user.name } },
|
|
846
|
+
{
|
|
847
|
+
status: 201,
|
|
848
|
+
headers: { "x-request-id": requestId }
|
|
849
|
+
}
|
|
850
|
+
);
|
|
851
|
+
}
|
|
852
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
853
|
+
return respondAction(
|
|
854
|
+
SILENT_SUCCESS_MESSAGE,
|
|
855
|
+
{},
|
|
856
|
+
{
|
|
857
|
+
status: 200,
|
|
858
|
+
headers: { "x-request-id": requestId }
|
|
859
|
+
}
|
|
860
|
+
);
|
|
861
|
+
} catch (err) {
|
|
862
|
+
if (NextlyError.isCode(err, "DUPLICATE") && !deps.revealRegistrationConflict) {
|
|
863
|
+
getNextlyLogger().info({
|
|
864
|
+
kind: "register-duplicate-swallowed",
|
|
865
|
+
requestId,
|
|
866
|
+
logContext: err.logContext
|
|
867
|
+
});
|
|
868
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
869
|
+
return respondAction(
|
|
870
|
+
SILENT_SUCCESS_MESSAGE,
|
|
871
|
+
{},
|
|
872
|
+
{
|
|
873
|
+
status: 200,
|
|
874
|
+
headers: { "x-request-id": requestId }
|
|
875
|
+
}
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
throw err;
|
|
879
|
+
}
|
|
880
|
+
} catch (err) {
|
|
881
|
+
await stallResponse(startTime, deps.loginStallTimeMs);
|
|
882
|
+
if (NextlyError.is(err)) {
|
|
883
|
+
return buildRegisterErrorResponse(err, requestId);
|
|
884
|
+
}
|
|
885
|
+
return buildRegisterErrorResponse(
|
|
886
|
+
NextlyError.internal({ cause: err }),
|
|
887
|
+
requestId
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// src/auth/handlers/reset-password.ts
|
|
893
|
+
function buildResetErrorResponse(err, requestId) {
|
|
894
|
+
return new Response(
|
|
895
|
+
JSON.stringify({ error: err.toResponseJSON(requestId) }),
|
|
896
|
+
{
|
|
897
|
+
status: err.statusCode,
|
|
898
|
+
headers: {
|
|
899
|
+
"content-type": "application/problem+json",
|
|
900
|
+
"x-request-id": requestId
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
function normaliseTokenFailure(err) {
|
|
906
|
+
if (NextlyError.is(err)) {
|
|
907
|
+
if (err.code === "NOT_FOUND" || err.code === "INVALID_INPUT" || err.code === "TOKEN_EXPIRED" || err.code === "VALIDATION_ERROR") {
|
|
908
|
+
return new NextlyError({
|
|
909
|
+
code: "INVALID_INPUT",
|
|
910
|
+
publicMessage: "This reset link is invalid or has expired.",
|
|
911
|
+
logContext: {
|
|
912
|
+
...err.logContext ?? {},
|
|
913
|
+
originalCode: err.code
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
throw err;
|
|
919
|
+
}
|
|
920
|
+
async function handleResetPassword(request, deps) {
|
|
921
|
+
const requestId = readOrGenerateRequestId(request);
|
|
922
|
+
try {
|
|
923
|
+
const body = await request.json();
|
|
924
|
+
const csrfCookie = readCsrfCookie(request);
|
|
925
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
926
|
+
const csrfResult = validateCsrf(
|
|
927
|
+
request,
|
|
928
|
+
csrfCookie,
|
|
929
|
+
csrfToken,
|
|
930
|
+
deps.allowedOrigins
|
|
931
|
+
);
|
|
932
|
+
if (!csrfResult.valid) {
|
|
933
|
+
return jsonResponse(
|
|
934
|
+
403,
|
|
935
|
+
{ error: { code: "CSRF_FAILED", message: csrfResult.error } },
|
|
936
|
+
{ "x-request-id": requestId }
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
const { token, newPassword } = body;
|
|
940
|
+
if (!token || !newPassword) {
|
|
941
|
+
throw NextlyError.validation({
|
|
942
|
+
errors: [
|
|
943
|
+
...!token ? [{ path: "token", code: "REQUIRED", message: "Required." }] : [],
|
|
944
|
+
...!newPassword ? [{ path: "newPassword", code: "REQUIRED", message: "Required." }] : []
|
|
945
|
+
]
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
let result;
|
|
949
|
+
try {
|
|
950
|
+
result = await deps.resetPasswordWithToken(token, newPassword);
|
|
951
|
+
} catch (err) {
|
|
952
|
+
throw normaliseTokenFailure(err);
|
|
953
|
+
}
|
|
954
|
+
if (result.email) {
|
|
955
|
+
const user = await deps.findUserByEmail(result.email);
|
|
956
|
+
if (user) {
|
|
957
|
+
await deps.deleteAllRefreshTokensForUser(user.id);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return respondAction(
|
|
961
|
+
"Password reset.",
|
|
962
|
+
{},
|
|
963
|
+
{ status: 200, headers: { "x-request-id": requestId } }
|
|
964
|
+
);
|
|
965
|
+
} catch (err) {
|
|
966
|
+
if (NextlyError.is(err)) {
|
|
967
|
+
return buildResetErrorResponse(err, requestId);
|
|
968
|
+
}
|
|
969
|
+
return buildResetErrorResponse(
|
|
970
|
+
NextlyError.internal({ cause: err }),
|
|
971
|
+
requestId
|
|
972
|
+
);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// src/auth/handlers/session.ts
|
|
977
|
+
var devAutoLoginWarned = /* @__PURE__ */ new Set();
|
|
978
|
+
async function handleSession(request, deps) {
|
|
979
|
+
const result = await getSession(request, deps.secret);
|
|
980
|
+
let failureReason = null;
|
|
981
|
+
if (!result.authenticated) {
|
|
982
|
+
failureReason = result.reason;
|
|
983
|
+
} else if (!deps.isProduction && deps.devAutoLogin) {
|
|
984
|
+
const live = await deps.findUserByEmail(result.user.email);
|
|
985
|
+
if (!live || live.id !== result.user.id) failureReason = "user_gone";
|
|
986
|
+
}
|
|
987
|
+
if (failureReason === null && result.authenticated) {
|
|
988
|
+
const accessToken = readAccessTokenCookie(request);
|
|
989
|
+
return respondData({ user: result.user, accessToken });
|
|
990
|
+
}
|
|
991
|
+
if (!deps.isProduction && deps.devAutoLogin) {
|
|
992
|
+
const autoLoginResponse = await attemptDevAutoLogin(deps);
|
|
993
|
+
if (autoLoginResponse) return autoLoginResponse;
|
|
994
|
+
} else if (deps.isProduction && deps.devAutoLogin) {
|
|
995
|
+
const key = deps.devAutoLogin.email;
|
|
996
|
+
if (!devAutoLoginWarned.has(`prod:${key}`)) {
|
|
997
|
+
devAutoLoginWarned.add(`prod:${key}`);
|
|
998
|
+
console.warn(
|
|
999
|
+
`[nextly] devAutoLogin ignored: NODE_ENV=production. Set this only for development. (configured email: ${key})`
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
const clearCookies = [];
|
|
1004
|
+
if (failureReason === "invalid" || failureReason === "user_gone") {
|
|
1005
|
+
clearCookies.push(clearAccessTokenCookie());
|
|
1006
|
+
}
|
|
1007
|
+
const code = failureReason === "expired" ? "TOKEN_EXPIRED" : "AUTH_REQUIRED";
|
|
1008
|
+
const message = code === "TOKEN_EXPIRED" ? "Session expired" : "Not authenticated";
|
|
1009
|
+
if (clearCookies.length > 0) {
|
|
1010
|
+
return new Response(JSON.stringify({ error: { code, message } }), {
|
|
1011
|
+
status: 401,
|
|
1012
|
+
headers: buildCookieHeaders(clearCookies)
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
return jsonResponse(401, { error: { code, message } });
|
|
1016
|
+
}
|
|
1017
|
+
async function attemptDevAutoLogin(deps) {
|
|
1018
|
+
if (!deps.devAutoLogin) return null;
|
|
1019
|
+
const { email } = deps.devAutoLogin;
|
|
1020
|
+
if (!email) return null;
|
|
1021
|
+
const user = await deps.findUserByEmail(email);
|
|
1022
|
+
if (!user || !user.isActive) {
|
|
1023
|
+
if (!devAutoLoginWarned.has(`miss:${email}`)) {
|
|
1024
|
+
devAutoLoginWarned.add(`miss:${email}`);
|
|
1025
|
+
console.warn(
|
|
1026
|
+
`[nextly] devAutoLogin: user "${email}" not found. Auto-login skipped. Either register the user or update admin.devAutoLogin.email in your nextly.config.ts.`
|
|
1027
|
+
);
|
|
1028
|
+
}
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
if (!devAutoLoginWarned.has(`active:${email}`)) {
|
|
1032
|
+
devAutoLoginWarned.add(`active:${email}`);
|
|
1033
|
+
console.warn(
|
|
1034
|
+
`[nextly] devAutoLogin enabled for ${email}. DO NOT use this in production deployments.`
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
const [roleIds, customFields] = await Promise.all([
|
|
1038
|
+
deps.fetchRoleIds(user.id),
|
|
1039
|
+
deps.fetchCustomFields(user.id)
|
|
1040
|
+
]);
|
|
1041
|
+
const claims = buildClaims({
|
|
1042
|
+
userId: user.id,
|
|
1043
|
+
email: user.email,
|
|
1044
|
+
name: user.name,
|
|
1045
|
+
image: user.image,
|
|
1046
|
+
roleIds,
|
|
1047
|
+
customFields
|
|
1048
|
+
});
|
|
1049
|
+
const accessToken = await signAccessToken(
|
|
1050
|
+
claims,
|
|
1051
|
+
deps.secret,
|
|
1052
|
+
deps.accessTokenTTL
|
|
1053
|
+
);
|
|
1054
|
+
const rawRefreshToken = generateRefreshToken();
|
|
1055
|
+
const refreshTokenHash = hashRefreshToken(rawRefreshToken);
|
|
1056
|
+
await deps.storeRefreshToken({
|
|
1057
|
+
id: generateRefreshTokenId(),
|
|
1058
|
+
userId: user.id,
|
|
1059
|
+
tokenHash: refreshTokenHash,
|
|
1060
|
+
userAgent: null,
|
|
1061
|
+
ipAddress: null,
|
|
1062
|
+
expiresAt: new Date(Date.now() + deps.refreshTokenTTL * 1e3)
|
|
1063
|
+
});
|
|
1064
|
+
const cookies = [
|
|
1065
|
+
setAccessTokenCookie(accessToken, deps.refreshTokenTTL, deps.isProduction),
|
|
1066
|
+
setRefreshTokenCookie(
|
|
1067
|
+
rawRefreshToken,
|
|
1068
|
+
deps.refreshTokenTTL,
|
|
1069
|
+
deps.isProduction
|
|
1070
|
+
)
|
|
1071
|
+
];
|
|
1072
|
+
return new Response(
|
|
1073
|
+
JSON.stringify({
|
|
1074
|
+
user: {
|
|
1075
|
+
id: user.id,
|
|
1076
|
+
email: user.email,
|
|
1077
|
+
name: user.name,
|
|
1078
|
+
image: user.image
|
|
1079
|
+
},
|
|
1080
|
+
accessToken
|
|
1081
|
+
}),
|
|
1082
|
+
{
|
|
1083
|
+
status: 200,
|
|
1084
|
+
headers: buildCookieHeaders(cookies)
|
|
1085
|
+
}
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// src/auth/handlers/setup.ts
|
|
1090
|
+
async function handleSetupStatus(_request, deps) {
|
|
1091
|
+
const count = await deps.getUserCount();
|
|
1092
|
+
const isSetup = count > 0;
|
|
1093
|
+
return respondData({ isSetup, requiresInitialUser: !isSetup });
|
|
1094
|
+
}
|
|
1095
|
+
async function handleSetup(request, deps) {
|
|
1096
|
+
const userCount = await deps.getUserCount();
|
|
1097
|
+
if (userCount > 0) {
|
|
1098
|
+
return jsonResponse(403, {
|
|
1099
|
+
error: {
|
|
1100
|
+
code: "SETUP_COMPLETE",
|
|
1101
|
+
message: "Setup already completed"
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
const body = await request.json();
|
|
1106
|
+
const csrfCookie = readCsrfCookie(request);
|
|
1107
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
1108
|
+
const csrfResult = validateCsrf(
|
|
1109
|
+
request,
|
|
1110
|
+
csrfCookie,
|
|
1111
|
+
csrfToken,
|
|
1112
|
+
deps.allowedOrigins
|
|
1113
|
+
);
|
|
1114
|
+
if (!csrfResult.valid) {
|
|
1115
|
+
return jsonResponse(403, {
|
|
1116
|
+
error: { code: "CSRF_FAILED", message: csrfResult.error }
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
const { email, name, password } = body;
|
|
1120
|
+
if (!email || !name || !password) {
|
|
1121
|
+
return jsonResponse(400, {
|
|
1122
|
+
error: {
|
|
1123
|
+
code: "VALIDATION_ERROR",
|
|
1124
|
+
message: "Email, name, and password are required"
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
const strengthResult = validatePasswordStrength(password);
|
|
1129
|
+
if (!strengthResult.ok) {
|
|
1130
|
+
return jsonResponse(400, {
|
|
1131
|
+
error: {
|
|
1132
|
+
code: "WEAK_PASSWORD",
|
|
1133
|
+
message: "Password does not meet requirements",
|
|
1134
|
+
details: strengthResult.errors
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
const user = await deps.createSuperAdmin({ email, name, password });
|
|
1139
|
+
const roleIds = await deps.fetchRoleIds(user.id);
|
|
1140
|
+
const claims = buildClaims({
|
|
1141
|
+
userId: user.id,
|
|
1142
|
+
email: user.email,
|
|
1143
|
+
name: user.name,
|
|
1144
|
+
image: null,
|
|
1145
|
+
roleIds
|
|
1146
|
+
});
|
|
1147
|
+
const accessToken = await signAccessToken(
|
|
1148
|
+
claims,
|
|
1149
|
+
deps.secret,
|
|
1150
|
+
deps.accessTokenTTL
|
|
1151
|
+
);
|
|
1152
|
+
const rawRefreshToken = generateRefreshToken();
|
|
1153
|
+
await deps.storeRefreshToken({
|
|
1154
|
+
id: generateRefreshTokenId(),
|
|
1155
|
+
userId: user.id,
|
|
1156
|
+
tokenHash: hashRefreshToken(rawRefreshToken),
|
|
1157
|
+
userAgent: request.headers.get("user-agent"),
|
|
1158
|
+
ipAddress: getTrustedClientIp(request, {
|
|
1159
|
+
trustProxy: deps.trustProxy,
|
|
1160
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
1161
|
+
}),
|
|
1162
|
+
expiresAt: new Date(Date.now() + deps.refreshTokenTTL * 1e3)
|
|
1163
|
+
});
|
|
1164
|
+
const cookies = [
|
|
1165
|
+
setAccessTokenCookie(accessToken, deps.refreshTokenTTL, deps.isProduction),
|
|
1166
|
+
setRefreshTokenCookie(
|
|
1167
|
+
rawRefreshToken,
|
|
1168
|
+
deps.refreshTokenTTL,
|
|
1169
|
+
deps.isProduction
|
|
1170
|
+
)
|
|
1171
|
+
];
|
|
1172
|
+
return respondAction(
|
|
1173
|
+
"Setup complete.",
|
|
1174
|
+
{
|
|
1175
|
+
user: { id: user.id, email: user.email, name: user.name, roleIds },
|
|
1176
|
+
accessToken,
|
|
1177
|
+
refreshToken: rawRefreshToken,
|
|
1178
|
+
// Authoritative server-side exp = accessToken JWT exp claim, not cookie max-age.
|
|
1179
|
+
expiresAt: new Date(
|
|
1180
|
+
Date.now() + deps.accessTokenTTL * 1e3
|
|
1181
|
+
).toISOString()
|
|
1182
|
+
},
|
|
1183
|
+
{ status: 201, headers: buildCookieHeaders(cookies) }
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// src/auth/handlers/verify-email.ts
|
|
1188
|
+
async function handleVerifyEmail(request, deps) {
|
|
1189
|
+
const body = await request.json();
|
|
1190
|
+
const { token } = body;
|
|
1191
|
+
if (!token) {
|
|
1192
|
+
return jsonResponse(400, {
|
|
1193
|
+
error: { code: "VALIDATION_ERROR", message: "Token is required" }
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
const result = await deps.verifyEmail(token);
|
|
1197
|
+
if (!result.success) {
|
|
1198
|
+
return jsonResponse(400, {
|
|
1199
|
+
error: {
|
|
1200
|
+
code: "VERIFICATION_FAILED",
|
|
1201
|
+
message: result.error || "Invalid or expired token"
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
return respondAction("Email verified.", { email: result.email });
|
|
1206
|
+
}
|
|
1207
|
+
async function handleResendVerification(request, deps) {
|
|
1208
|
+
const body = await request.json();
|
|
1209
|
+
const csrfCookie = readCsrfCookie(request);
|
|
1210
|
+
const csrfToken = readCsrfFromRequest(body, request);
|
|
1211
|
+
const csrfResult = validateCsrf(
|
|
1212
|
+
request,
|
|
1213
|
+
csrfCookie,
|
|
1214
|
+
csrfToken,
|
|
1215
|
+
deps.allowedOrigins
|
|
1216
|
+
);
|
|
1217
|
+
if (!csrfResult.valid) {
|
|
1218
|
+
return jsonResponse(403, {
|
|
1219
|
+
error: { code: "CSRF_FAILED", message: csrfResult.error }
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
const { email } = body;
|
|
1223
|
+
if (!email) {
|
|
1224
|
+
return jsonResponse(400, {
|
|
1225
|
+
error: { code: "VALIDATION_ERROR", message: "Email is required" }
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
await deps.resendVerificationEmail(email);
|
|
1229
|
+
return respondAction("Verification email sent.");
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// src/auth/handlers/router.ts
|
|
1233
|
+
var RATE_LIMITED_AUTH_PATHS = /* @__PURE__ */ new Set([
|
|
1234
|
+
"login",
|
|
1235
|
+
"register",
|
|
1236
|
+
"forgot-password",
|
|
1237
|
+
"reset-password"
|
|
1238
|
+
]);
|
|
1239
|
+
async function routeAuthRequest(request, authPath, deps) {
|
|
1240
|
+
const response = await dispatchAuthRequest(request, authPath, deps);
|
|
1241
|
+
return applyNoStoreCache(response);
|
|
1242
|
+
}
|
|
1243
|
+
async function dispatchAuthRequest(request, authPath, deps) {
|
|
1244
|
+
const method = request.method.toUpperCase();
|
|
1245
|
+
if (method === "GET") {
|
|
1246
|
+
switch (authPath) {
|
|
1247
|
+
case "setup-status":
|
|
1248
|
+
return handleSetupStatus(request, deps);
|
|
1249
|
+
case "session":
|
|
1250
|
+
return handleSession(request, deps);
|
|
1251
|
+
case "csrf":
|
|
1252
|
+
return handleCsrf(request, deps);
|
|
1253
|
+
default:
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (method === "POST") {
|
|
1258
|
+
if (RATE_LIMITED_AUTH_PATHS.has(authPath)) {
|
|
1259
|
+
const limited = checkAuthIpRateLimit(request, deps);
|
|
1260
|
+
if (limited) return limited;
|
|
1261
|
+
}
|
|
1262
|
+
return auditCsrfFailure(request, authPath, deps, () => {
|
|
1263
|
+
switch (authPath) {
|
|
1264
|
+
case "login":
|
|
1265
|
+
return handleLogin(request, deps);
|
|
1266
|
+
case "logout":
|
|
1267
|
+
return handleLogout(request, deps);
|
|
1268
|
+
case "refresh":
|
|
1269
|
+
return handleRefresh(request, deps);
|
|
1270
|
+
case "setup":
|
|
1271
|
+
return handleSetup(request, deps);
|
|
1272
|
+
case "register":
|
|
1273
|
+
return handleRegister(request, deps);
|
|
1274
|
+
case "forgot-password":
|
|
1275
|
+
return handleForgotPassword(request, deps);
|
|
1276
|
+
case "reset-password":
|
|
1277
|
+
return handleResetPassword(request, deps);
|
|
1278
|
+
case "verify-email":
|
|
1279
|
+
return handleVerifyEmail(request, deps);
|
|
1280
|
+
case "verify-email/resend":
|
|
1281
|
+
return handleResendVerification(request, deps);
|
|
1282
|
+
default:
|
|
1283
|
+
return Promise.resolve(null);
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
if (method === "PATCH") {
|
|
1288
|
+
return auditCsrfFailure(request, authPath, deps, () => {
|
|
1289
|
+
switch (authPath) {
|
|
1290
|
+
case "change-password":
|
|
1291
|
+
return handleChangePassword(request, deps);
|
|
1292
|
+
default:
|
|
1293
|
+
return Promise.resolve(null);
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
return null;
|
|
1298
|
+
}
|
|
1299
|
+
function applyNoStoreCache(response) {
|
|
1300
|
+
if (!response) return response;
|
|
1301
|
+
response.headers.set("Cache-Control", "no-store");
|
|
1302
|
+
response.headers.set("Pragma", "no-cache");
|
|
1303
|
+
return response;
|
|
1304
|
+
}
|
|
1305
|
+
async function auditCsrfFailure(request, authPath, deps, dispatch) {
|
|
1306
|
+
const response = await dispatch();
|
|
1307
|
+
if (!response || response.status !== 403) return response;
|
|
1308
|
+
try {
|
|
1309
|
+
const body = await response.clone().json();
|
|
1310
|
+
if (body?.error?.code !== "CSRF_FAILED") return response;
|
|
1311
|
+
await deps.auditLog.write({
|
|
1312
|
+
kind: "csrf-failed",
|
|
1313
|
+
ipAddress: getTrustedClientIp(request, {
|
|
1314
|
+
trustProxy: deps.trustProxy,
|
|
1315
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
1316
|
+
}),
|
|
1317
|
+
userAgent: request.headers.get("user-agent"),
|
|
1318
|
+
metadata: { path: authPath, method: request.method.toUpperCase() }
|
|
1319
|
+
});
|
|
1320
|
+
} catch {
|
|
1321
|
+
}
|
|
1322
|
+
return response;
|
|
1323
|
+
}
|
|
1324
|
+
function checkAuthIpRateLimit(request, deps) {
|
|
1325
|
+
const limit = deps.authRateLimit.requestsPerHour;
|
|
1326
|
+
if (limit <= 0) return null;
|
|
1327
|
+
const ip = getTrustedClientIp(request, {
|
|
1328
|
+
trustProxy: deps.trustProxy,
|
|
1329
|
+
trustedProxyIps: deps.trustedProxyIps
|
|
1330
|
+
}) ?? "unknown";
|
|
1331
|
+
const result = rateLimiter.check(
|
|
1332
|
+
`auth-ip:${ip}`,
|
|
1333
|
+
limit,
|
|
1334
|
+
deps.authRateLimit.windowMs
|
|
1335
|
+
);
|
|
1336
|
+
if (result.allowed) return null;
|
|
1337
|
+
const retryAfter = Math.max(
|
|
1338
|
+
1,
|
|
1339
|
+
Math.ceil((result.resetAt.getTime() - Date.now()) / 1e3)
|
|
1340
|
+
);
|
|
1341
|
+
return new Response(
|
|
1342
|
+
JSON.stringify({
|
|
1343
|
+
error: {
|
|
1344
|
+
code: "RATE_LIMIT_EXCEEDED",
|
|
1345
|
+
message: "Too many auth requests from this IP. Please try again later.",
|
|
1346
|
+
retryAfter
|
|
1347
|
+
}
|
|
1348
|
+
}),
|
|
1349
|
+
{
|
|
1350
|
+
status: 429,
|
|
1351
|
+
headers: {
|
|
1352
|
+
"content-type": "application/json",
|
|
1353
|
+
"retry-after": String(retryAfter),
|
|
1354
|
+
"x-ratelimit-limit": String(limit),
|
|
1355
|
+
"x-ratelimit-remaining": "0",
|
|
1356
|
+
"x-ratelimit-reset": String(result.resetAt.getTime())
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
export {
|
|
1363
|
+
routeAuthRequest
|
|
1364
|
+
};
|