@nuraly/lumenjs 0.1.3 → 0.2.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 +62 -282
- package/dist/auth/config.d.ts +23 -0
- package/dist/auth/config.js +115 -0
- package/dist/auth/guard.d.ts +12 -0
- package/dist/auth/guard.js +28 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.js +1 -0
- package/dist/auth/middleware.d.ts +23 -0
- package/dist/auth/middleware.js +89 -0
- package/dist/auth/native-auth.d.ts +82 -0
- package/dist/auth/native-auth.js +340 -0
- package/dist/auth/oidc-client.d.ts +17 -0
- package/dist/auth/oidc-client.js +123 -0
- package/dist/auth/providers/google.d.ts +23 -0
- package/dist/auth/providers/google.js +25 -0
- package/dist/auth/providers/index.d.ts +2 -0
- package/dist/auth/providers/index.js +1 -0
- package/dist/auth/routes/login.d.ts +8 -0
- package/dist/auth/routes/login.js +121 -0
- package/dist/auth/routes/logout.d.ts +4 -0
- package/dist/auth/routes/logout.js +79 -0
- package/dist/auth/routes/oidc-callback.d.ts +3 -0
- package/dist/auth/routes/oidc-callback.js +70 -0
- package/dist/auth/routes/password.d.ts +5 -0
- package/dist/auth/routes/password.js +149 -0
- package/dist/auth/routes/signup.d.ts +3 -0
- package/dist/auth/routes/signup.js +81 -0
- package/dist/auth/routes/token.d.ts +4 -0
- package/dist/auth/routes/token.js +70 -0
- package/dist/auth/routes/totp.d.ts +22 -0
- package/dist/auth/routes/totp.js +232 -0
- package/dist/auth/routes/utils.d.ts +7 -0
- package/dist/auth/routes/utils.js +35 -0
- package/dist/auth/routes/verify.d.ts +3 -0
- package/dist/auth/routes/verify.js +26 -0
- package/dist/auth/routes.d.ts +8 -0
- package/dist/auth/routes.js +124 -0
- package/dist/auth/session.d.ts +8 -0
- package/dist/auth/session.js +54 -0
- package/dist/auth/token.d.ts +33 -0
- package/dist/auth/token.js +90 -0
- package/dist/auth/types.d.ts +156 -0
- package/dist/auth/types.js +2 -0
- package/dist/build/build-client.d.ts +15 -0
- package/dist/build/build-client.js +45 -0
- package/dist/build/build-prerender.d.ts +11 -0
- package/dist/build/build-prerender.js +159 -0
- package/dist/build/build-server.d.ts +18 -0
- package/dist/build/build-server.js +107 -0
- package/dist/build/build.js +60 -123
- package/dist/build/scan.d.ts +18 -0
- package/dist/build/scan.js +77 -6
- package/dist/build/serve-api.js +8 -2
- package/dist/build/serve-loaders.d.ts +4 -4
- package/dist/build/serve-loaders.js +26 -18
- package/dist/build/serve-ssr.js +38 -11
- package/dist/build/serve-static.js +3 -3
- package/dist/build/serve.js +341 -18
- package/dist/cli.js +37 -6
- package/dist/communication/encryption.d.ts +35 -0
- package/dist/communication/encryption.js +90 -0
- package/dist/communication/handlers/context.d.ts +27 -0
- package/dist/communication/handlers/context.js +1 -0
- package/dist/communication/handlers/conversation.d.ts +24 -0
- package/dist/communication/handlers/conversation.js +113 -0
- package/dist/communication/handlers/file-upload.d.ts +17 -0
- package/dist/communication/handlers/file-upload.js +62 -0
- package/dist/communication/handlers/messaging.d.ts +30 -0
- package/dist/communication/handlers/messaging.js +237 -0
- package/dist/communication/handlers/presence.d.ts +15 -0
- package/dist/communication/handlers/presence.js +76 -0
- package/dist/communication/handlers.d.ts +5 -0
- package/dist/communication/handlers.js +5 -0
- package/dist/communication/index.d.ts +9 -0
- package/dist/communication/index.js +7 -0
- package/dist/communication/link-preview.d.ts +18 -0
- package/dist/communication/link-preview.js +115 -0
- package/dist/communication/schema.d.ts +10 -0
- package/dist/communication/schema.js +101 -0
- package/dist/communication/server.d.ts +86 -0
- package/dist/communication/server.js +212 -0
- package/dist/communication/signaling.d.ts +43 -0
- package/dist/communication/signaling.js +271 -0
- package/dist/communication/store.d.ts +71 -0
- package/dist/communication/store.js +289 -0
- package/dist/communication/types.d.ts +454 -0
- package/dist/communication/types.js +1 -0
- package/dist/create.d.ts +1 -0
- package/dist/create.js +55 -0
- package/dist/db/auto-migrate.d.ts +3 -0
- package/dist/db/auto-migrate.js +100 -0
- package/dist/db/client.d.ts +3 -0
- package/dist/db/client.js +18 -0
- package/dist/db/index.d.ts +17 -13
- package/dist/db/index.js +205 -26
- package/dist/db/seed.d.ts +12 -0
- package/dist/db/seed.js +88 -0
- package/dist/db/table.d.ts +10 -0
- package/dist/db/table.js +12 -0
- package/dist/dev-server/config.d.ts +11 -0
- package/dist/dev-server/config.js +40 -20
- package/dist/dev-server/index-html.d.ts +4 -0
- package/dist/dev-server/index-html.js +21 -6
- package/dist/dev-server/nuralyui-aliases.d.ts +0 -4
- package/dist/dev-server/nuralyui-aliases.js +115 -94
- package/dist/dev-server/plugins/vite-plugin-api-routes.js +29 -5
- package/dist/dev-server/plugins/vite-plugin-auth.d.ts +6 -0
- package/dist/dev-server/plugins/vite-plugin-auth.js +223 -0
- package/dist/dev-server/plugins/vite-plugin-auto-define.d.ts +16 -0
- package/dist/dev-server/plugins/vite-plugin-auto-define.js +111 -0
- package/dist/dev-server/plugins/vite-plugin-communication.d.ts +6 -0
- package/dist/dev-server/plugins/vite-plugin-communication.js +205 -0
- package/dist/dev-server/plugins/vite-plugin-editor-api.d.ts +6 -0
- package/dist/dev-server/plugins/vite-plugin-editor-api.js +318 -0
- package/dist/dev-server/plugins/vite-plugin-i18n.js +69 -2
- package/dist/dev-server/plugins/vite-plugin-lit-dedup.d.ts +6 -0
- package/dist/dev-server/plugins/vite-plugin-lit-dedup.js +78 -34
- package/dist/dev-server/plugins/vite-plugin-lit-hmr.js +44 -2
- package/dist/dev-server/plugins/vite-plugin-llms.d.ts +2 -0
- package/dist/dev-server/plugins/vite-plugin-llms.js +92 -0
- package/dist/dev-server/plugins/vite-plugin-loaders.js +146 -13
- package/dist/dev-server/plugins/vite-plugin-routes.js +16 -5
- package/dist/dev-server/plugins/vite-plugin-socketio.d.ts +2 -0
- package/dist/dev-server/plugins/vite-plugin-socketio.js +51 -0
- package/dist/dev-server/plugins/vite-plugin-source-annotator.d.ts +2 -0
- package/dist/dev-server/plugins/vite-plugin-source-annotator.js +26 -3
- package/dist/dev-server/plugins/vite-plugin-storage.d.ts +10 -0
- package/dist/dev-server/plugins/vite-plugin-storage.js +126 -0
- package/dist/dev-server/plugins/vite-plugin-virtual-modules.js +140 -3
- package/dist/dev-server/server.js +242 -70
- package/dist/dev-server/ssr-render.d.ts +2 -1
- package/dist/dev-server/ssr-render.js +117 -50
- package/dist/editor/ai/backend.d.ts +20 -0
- package/dist/editor/ai/backend.js +113 -0
- package/dist/editor/ai/claude-code-client.d.ts +20 -0
- package/dist/editor/ai/claude-code-client.js +145 -0
- package/dist/editor/ai/deepseek-client.d.ts +7 -0
- package/dist/editor/ai/deepseek-client.js +113 -0
- package/dist/editor/ai/opencode-client.d.ts +14 -0
- package/dist/editor/ai/opencode-client.js +99 -0
- package/dist/editor/ai/snapshot-store.d.ts +22 -0
- package/dist/editor/ai/snapshot-store.js +35 -0
- package/dist/editor/ai/types.d.ts +30 -0
- package/dist/editor/ai/types.js +136 -0
- package/dist/editor/ai-chat-panel.d.ts +13 -0
- package/dist/editor/ai-chat-panel.js +613 -0
- package/dist/editor/ai-markdown.d.ts +10 -0
- package/dist/editor/ai-markdown.js +70 -0
- package/dist/editor/ai-project-panel.d.ts +11 -0
- package/dist/editor/ai-project-panel.js +332 -0
- package/dist/editor/ast-modification.d.ts +11 -0
- package/dist/editor/ast-modification.js +1 -0
- package/dist/editor/ast-service.d.ts +30 -0
- package/dist/editor/ast-service.js +180 -0
- package/dist/editor/css-rules.d.ts +54 -0
- package/dist/editor/css-rules.js +423 -0
- package/dist/editor/editor-api-client.d.ts +51 -0
- package/dist/editor/editor-api-client.js +162 -0
- package/dist/editor/editor-bridge.d.ts +1 -0
- package/dist/editor/editor-bridge.js +18 -8
- package/dist/editor/editor-toolbar.d.ts +14 -0
- package/dist/editor/editor-toolbar.js +115 -0
- package/dist/editor/file-editor.d.ts +9 -0
- package/dist/editor/file-editor.js +236 -0
- package/dist/editor/file-service.d.ts +16 -0
- package/dist/editor/file-service.js +52 -0
- package/dist/editor/i18n-key-gen.d.ts +1 -0
- package/dist/editor/i18n-key-gen.js +7 -0
- package/dist/editor/inline-text-edit.d.ts +5 -0
- package/dist/editor/inline-text-edit.js +173 -92
- package/dist/editor/overlay-events.d.ts +5 -0
- package/dist/editor/overlay-events.js +364 -0
- package/dist/editor/overlay-hmr.d.ts +2 -0
- package/dist/editor/overlay-hmr.js +76 -0
- package/dist/editor/overlay-selection.d.ts +29 -0
- package/dist/editor/overlay-selection.js +148 -0
- package/dist/editor/overlay-utils.d.ts +12 -0
- package/dist/editor/overlay-utils.js +59 -0
- package/dist/editor/properties-panel-persist.d.ts +14 -0
- package/dist/editor/properties-panel-persist.js +70 -0
- package/dist/editor/properties-panel-rows.d.ts +10 -0
- package/dist/editor/properties-panel-rows.js +349 -0
- package/dist/editor/properties-panel-styles.d.ts +4 -0
- package/dist/editor/properties-panel-styles.js +174 -0
- package/dist/editor/properties-panel.d.ts +4 -0
- package/dist/editor/properties-panel.js +148 -0
- package/dist/editor/property-registry.d.ts +16 -0
- package/dist/editor/property-registry.js +303 -0
- package/dist/editor/standalone-file-panel.d.ts +0 -0
- package/dist/editor/standalone-file-panel.js +1 -0
- package/dist/editor/standalone-overlay-dom.d.ts +0 -0
- package/dist/editor/standalone-overlay-dom.js +1 -0
- package/dist/editor/standalone-overlay-styles.d.ts +0 -0
- package/dist/editor/standalone-overlay-styles.js +1 -0
- package/dist/editor/standalone-overlay.d.ts +1 -0
- package/dist/editor/standalone-overlay.js +76 -0
- package/dist/editor/syntax-highlighter.d.ts +4 -0
- package/dist/editor/syntax-highlighter.js +81 -0
- package/dist/editor/text-toolbar.d.ts +11 -0
- package/dist/editor/text-toolbar.js +327 -0
- package/dist/editor/toolbar-styles.d.ts +4 -0
- package/dist/editor/toolbar-styles.js +198 -0
- package/dist/email/index.d.ts +32 -0
- package/dist/email/index.js +154 -0
- package/dist/email/providers/resend.d.ts +2 -0
- package/dist/email/providers/resend.js +24 -0
- package/dist/email/providers/sendgrid.d.ts +2 -0
- package/dist/email/providers/sendgrid.js +31 -0
- package/dist/email/providers/smtp.d.ts +13 -0
- package/dist/email/providers/smtp.js +125 -0
- package/dist/email/template-engine.d.ts +18 -0
- package/dist/email/template-engine.js +116 -0
- package/dist/email/templates/base.d.ts +9 -0
- package/dist/email/templates/base.js +65 -0
- package/dist/email/templates/password-reset.d.ts +5 -0
- package/dist/email/templates/password-reset.js +15 -0
- package/dist/email/templates/verify-email.d.ts +5 -0
- package/dist/email/templates/verify-email.js +15 -0
- package/dist/email/templates/welcome.d.ts +5 -0
- package/dist/email/templates/welcome.js +13 -0
- package/dist/email/types.d.ts +49 -0
- package/dist/email/types.js +1 -0
- package/dist/llms/generate.d.ts +46 -0
- package/dist/llms/generate.js +185 -0
- package/dist/permissions/guard.d.ts +28 -0
- package/dist/permissions/guard.js +30 -0
- package/dist/permissions/index.d.ts +6 -0
- package/dist/permissions/index.js +3 -0
- package/dist/permissions/service.d.ts +80 -0
- package/dist/permissions/service.js +210 -0
- package/dist/permissions/tables.d.ts +5 -0
- package/dist/permissions/tables.js +68 -0
- package/dist/permissions/types.d.ts +33 -0
- package/dist/permissions/types.js +1 -0
- package/dist/runtime/app-shell.d.ts +1 -1
- package/dist/runtime/app-shell.js +164 -0
- package/dist/runtime/auth.d.ts +10 -0
- package/dist/runtime/auth.js +30 -0
- package/dist/runtime/communication.d.ts +137 -0
- package/dist/runtime/communication.js +228 -0
- package/dist/runtime/error-boundary.d.ts +23 -0
- package/dist/runtime/error-boundary.js +120 -0
- package/dist/runtime/i18n.d.ts +6 -1
- package/dist/runtime/i18n.js +42 -21
- package/dist/runtime/island.d.ts +16 -0
- package/dist/runtime/island.js +80 -0
- package/dist/runtime/router-data.d.ts +3 -0
- package/dist/runtime/router-data.js +102 -17
- package/dist/runtime/router-hydration.js +34 -2
- package/dist/runtime/router.d.ts +19 -2
- package/dist/runtime/router.js +237 -43
- package/dist/runtime/socket-client.d.ts +2 -0
- package/dist/runtime/socket-client.js +30 -0
- package/dist/runtime/webrtc.d.ts +91 -0
- package/dist/runtime/webrtc.js +428 -0
- package/dist/shared/dom-shims.js +4 -2
- package/dist/shared/graceful-shutdown.d.ts +8 -0
- package/dist/shared/graceful-shutdown.js +36 -0
- package/dist/shared/health.d.ts +8 -0
- package/dist/shared/health.js +25 -0
- package/dist/shared/llms-txt.d.ts +31 -0
- package/dist/shared/llms-txt.js +85 -0
- package/dist/shared/logger.d.ts +32 -0
- package/dist/shared/logger.js +93 -0
- package/dist/shared/meta.d.ts +27 -0
- package/dist/shared/meta.js +71 -0
- package/dist/shared/middleware-runner.d.ts +9 -0
- package/dist/shared/middleware-runner.js +29 -0
- package/dist/shared/rate-limit.d.ts +18 -0
- package/dist/shared/rate-limit.js +71 -0
- package/dist/shared/request-id.d.ts +5 -0
- package/dist/shared/request-id.js +18 -0
- package/dist/shared/route-matching.js +16 -1
- package/dist/shared/security-headers.d.ts +18 -0
- package/dist/shared/security-headers.js +38 -0
- package/dist/shared/socket-io-setup.d.ts +11 -0
- package/dist/shared/socket-io-setup.js +51 -0
- package/dist/shared/types.d.ts +15 -0
- package/dist/shared/utils.d.ts +33 -7
- package/dist/shared/utils.js +164 -27
- package/dist/storage/adapters/local.d.ts +44 -0
- package/dist/storage/adapters/local.js +85 -0
- package/dist/storage/adapters/s3.d.ts +32 -0
- package/dist/storage/adapters/s3.js +119 -0
- package/dist/storage/adapters/types.d.ts +53 -0
- package/dist/storage/adapters/types.js +1 -0
- package/dist/storage/index.d.ts +76 -0
- package/dist/storage/index.js +83 -0
- package/package.json +45 -7
- package/templates/blog/api/posts.ts +4 -18
- package/templates/blog/data/migrations/001_init.sql +6 -5
- package/templates/blog/lumenjs.config.ts +3 -0
- package/templates/blog/package.json +14 -0
- package/templates/blog/pages/_layout.ts +25 -0
- package/templates/blog/pages/index.ts +48 -22
- package/templates/blog/pages/posts/[slug].ts +45 -20
- package/templates/blog/pages/tag/[tag].ts +44 -0
- package/templates/dashboard/api/stats.ts +8 -5
- package/templates/dashboard/lumenjs.config.ts +3 -0
- package/templates/dashboard/package.json +14 -0
- package/templates/dashboard/pages/_layout.ts +25 -0
- package/templates/dashboard/pages/index.ts +54 -23
- package/templates/dashboard/pages/settings/index.ts +29 -0
- package/templates/default/lumenjs.config.ts +3 -0
- package/templates/default/package.json +14 -0
- package/templates/default/pages/index.ts +24 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { sendJson, readBody } from './utils.js';
|
|
2
|
+
import { encryptSession, createSessionCookie, decryptSession } from '../session.js';
|
|
3
|
+
import { encryptTotpSecret, decryptTotpSecret, saveTotpSecret, enableTotp, disableTotp, getTotpState, } from '../native-auth.js';
|
|
4
|
+
function parseCookies(req) {
|
|
5
|
+
const cookies = {};
|
|
6
|
+
const header = req.headers.cookie || '';
|
|
7
|
+
for (const part of header.split(';')) {
|
|
8
|
+
const [k, ...v] = part.trim().split('=');
|
|
9
|
+
if (k)
|
|
10
|
+
cookies[k.trim()] = decodeURIComponent(v.join('='));
|
|
11
|
+
}
|
|
12
|
+
return cookies;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* POST /__nk_auth/totp/setup
|
|
16
|
+
* Generates a new TOTP secret for the authenticated user and returns a QR code.
|
|
17
|
+
*/
|
|
18
|
+
export async function handleTotpSetup(config, req, res, db) {
|
|
19
|
+
const user = req.nkAuth?.user;
|
|
20
|
+
if (!user?.sub) {
|
|
21
|
+
sendJson(res, 401, { error: 'Not authenticated' });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
if (!db) {
|
|
25
|
+
sendJson(res, 500, { error: 'Database unavailable' });
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const { authenticator } = await import('otplib');
|
|
30
|
+
const QRCode = await import('qrcode');
|
|
31
|
+
const secret = authenticator.generateSecret();
|
|
32
|
+
const appName = config.totp?.appName || 'Nuraly';
|
|
33
|
+
const otpauthUri = authenticator.keyuri(user.email || user.sub, appName, secret);
|
|
34
|
+
const qrDataUrl = await QRCode.default.toDataURL(otpauthUri);
|
|
35
|
+
const encrypted = await encryptTotpSecret(secret, config.session.secret);
|
|
36
|
+
await saveTotpSecret(db, user.sub, encrypted);
|
|
37
|
+
sendJson(res, 200, { qrDataUrl, otpauthUri });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
sendJson(res, 500, { error: err.message || 'Setup failed' });
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* POST /__nk_auth/totp/verify-setup
|
|
46
|
+
* Confirms setup by verifying the first 6-digit code and enables TOTP.
|
|
47
|
+
*/
|
|
48
|
+
export async function handleTotpVerifySetup(config, req, res, db) {
|
|
49
|
+
const user = req.nkAuth?.user;
|
|
50
|
+
if (!user?.sub) {
|
|
51
|
+
sendJson(res, 401, { error: 'Not authenticated' });
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (!db) {
|
|
55
|
+
sendJson(res, 500, { error: 'Database unavailable' });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = JSON.parse(await readBody(req));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
sendJson(res, 400, { error: 'Invalid JSON' });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const { code } = body;
|
|
67
|
+
if (!code) {
|
|
68
|
+
sendJson(res, 400, { error: 'Code required' });
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const { authenticator } = await import('otplib');
|
|
73
|
+
const state = await getTotpState(db, user.sub);
|
|
74
|
+
if (!state.encryptedSecret) {
|
|
75
|
+
sendJson(res, 400, { error: 'No pending TOTP setup' });
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
const secret = await decryptTotpSecret(state.encryptedSecret, config.session.secret);
|
|
79
|
+
const valid = authenticator.check(code, secret);
|
|
80
|
+
if (!valid) {
|
|
81
|
+
sendJson(res, 400, { error: 'Invalid code — check your authenticator app and try again' });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
await enableTotp(db, user.sub);
|
|
85
|
+
sendJson(res, 200, { ok: true });
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
sendJson(res, 500, { error: err.message || 'Verification failed' });
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* POST /__nk_auth/totp/disable
|
|
94
|
+
* Disables TOTP after verifying a valid code.
|
|
95
|
+
*/
|
|
96
|
+
export async function handleTotpDisable(config, req, res, db) {
|
|
97
|
+
const user = req.nkAuth?.user;
|
|
98
|
+
if (!user?.sub) {
|
|
99
|
+
sendJson(res, 401, { error: 'Not authenticated' });
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
if (!db) {
|
|
103
|
+
sendJson(res, 500, { error: 'Database unavailable' });
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
let body;
|
|
107
|
+
try {
|
|
108
|
+
body = JSON.parse(await readBody(req));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
sendJson(res, 400, { error: 'Invalid JSON' });
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
const { code } = body;
|
|
115
|
+
if (!code) {
|
|
116
|
+
sendJson(res, 400, { error: 'Code required' });
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const { authenticator } = await import('otplib');
|
|
121
|
+
const state = await getTotpState(db, user.sub);
|
|
122
|
+
if (!state.totpEnabled || !state.encryptedSecret) {
|
|
123
|
+
sendJson(res, 400, { error: '2FA is not enabled' });
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const secret = await decryptTotpSecret(state.encryptedSecret, config.session.secret);
|
|
127
|
+
const valid = authenticator.check(code, secret);
|
|
128
|
+
if (!valid) {
|
|
129
|
+
sendJson(res, 400, { error: 'Invalid code' });
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
await disableTotp(db, user.sub);
|
|
133
|
+
sendJson(res, 200, { ok: true });
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
sendJson(res, 500, { error: err.message || 'Failed to disable 2FA' });
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* POST /__nk_auth/totp/challenge
|
|
142
|
+
* Exchanges a pending-2FA cookie + valid TOTP code for a full session cookie.
|
|
143
|
+
*/
|
|
144
|
+
export async function handleTotpChallenge(config, req, res, db) {
|
|
145
|
+
if (!db) {
|
|
146
|
+
sendJson(res, 500, { error: 'Database unavailable' });
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
const cookies = parseCookies(req);
|
|
150
|
+
const pendingEncrypted = cookies['nk-totp-pending'];
|
|
151
|
+
if (!pendingEncrypted) {
|
|
152
|
+
sendJson(res, 401, { error: 'No pending 2FA session' });
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
let body;
|
|
156
|
+
try {
|
|
157
|
+
body = JSON.parse(await readBody(req));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
sendJson(res, 400, { error: 'Invalid JSON' });
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
const { code } = body;
|
|
164
|
+
if (!code) {
|
|
165
|
+
sendJson(res, 400, { error: 'Code required' });
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
// Decrypt and validate the pending session
|
|
170
|
+
const pending = await decryptSession(pendingEncrypted, config.session.secret);
|
|
171
|
+
if (!pending || !pending.accessToken.startsWith('totp-pending:')) {
|
|
172
|
+
sendJson(res, 401, { error: 'Invalid pending session' });
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
if (pending.expiresAt < Math.floor(Date.now() / 1000)) {
|
|
176
|
+
sendJson(res, 401, { error: '2FA session expired — please log in again' });
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
const userId = pending.accessToken.slice('totp-pending:'.length);
|
|
180
|
+
// Fetch full user row
|
|
181
|
+
const row = await db.get('SELECT * FROM _nk_auth_users WHERE id = ?', userId);
|
|
182
|
+
if (!row) {
|
|
183
|
+
sendJson(res, 401, { error: 'User not found' });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
// Verify TOTP code
|
|
187
|
+
const { authenticator } = await import('otplib');
|
|
188
|
+
if (!row.totp_enabled || !row.totp_secret) {
|
|
189
|
+
sendJson(res, 400, { error: '2FA not enabled for this account' });
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
const secret = await decryptTotpSecret(row.totp_secret, config.session.secret);
|
|
193
|
+
const valid = authenticator.check(code, secret);
|
|
194
|
+
if (!valid) {
|
|
195
|
+
sendJson(res, 400, { error: 'Invalid code — check your authenticator app and try again' });
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
// Build full user object
|
|
199
|
+
let roles = [];
|
|
200
|
+
try {
|
|
201
|
+
roles = JSON.parse(row.roles);
|
|
202
|
+
}
|
|
203
|
+
catch { }
|
|
204
|
+
const user = { sub: row.id, email: row.email, name: row.name, roles, provider: 'native' };
|
|
205
|
+
// Issue full session cookie
|
|
206
|
+
const sessionData = {
|
|
207
|
+
accessToken: `native:${user.sub}`,
|
|
208
|
+
expiresAt: Math.floor(Date.now() / 1000) + config.session.maxAge,
|
|
209
|
+
user,
|
|
210
|
+
provider: 'native',
|
|
211
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
212
|
+
};
|
|
213
|
+
const encrypted = await encryptSession(sessionData, config.session.secret);
|
|
214
|
+
const sessionCookie = createSessionCookie(config.session.cookieName, encrypted, config.session.maxAge, config.session.secure);
|
|
215
|
+
// Clear the pending cookie
|
|
216
|
+
const clearPending = `nk-totp-pending=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax${config.session.secure ? '; Secure' : ''}`;
|
|
217
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
218
|
+
const returnTo = url.searchParams.get('returnTo') || config.routes.postLogin;
|
|
219
|
+
if (req.headers.accept?.includes('application/json')) {
|
|
220
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Set-Cookie': [sessionCookie, clearPending] });
|
|
221
|
+
res.end(JSON.stringify({ user, returnTo }));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
res.writeHead(302, { Location: returnTo, 'Set-Cookie': [sessionCookie, clearPending] });
|
|
225
|
+
res.end();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
sendJson(res, 500, { error: err.message || 'Challenge failed' });
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
2
|
+
export declare function readBody(req: IncomingMessage, maxSize?: number): Promise<string>;
|
|
3
|
+
export declare function sendJson(res: ServerResponse, status: number, data: any): void;
|
|
4
|
+
/** Check if request wants token-based auth (mobile) -- only with explicit ?mode=token */
|
|
5
|
+
export declare function isTokenMode(url: URL, _req: IncomingMessage): boolean;
|
|
6
|
+
/** Validate returnTo is a safe relative path (prevents open redirect). */
|
|
7
|
+
export declare function safeReturnTo(returnTo: string | null, fallback: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const MAX_BODY_SIZE = 64 * 1024; // 64 KB — sufficient for auth payloads
|
|
2
|
+
export function readBody(req, maxSize = MAX_BODY_SIZE) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
let size = 0;
|
|
6
|
+
req.on('data', (c) => {
|
|
7
|
+
size += c.length;
|
|
8
|
+
if (size > maxSize) {
|
|
9
|
+
req.destroy();
|
|
10
|
+
reject(new Error('Request body too large'));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
chunks.push(c);
|
|
14
|
+
});
|
|
15
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
|
|
16
|
+
req.on('error', reject);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export function sendJson(res, status, data) {
|
|
20
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
21
|
+
res.end(JSON.stringify(data));
|
|
22
|
+
}
|
|
23
|
+
/** Check if request wants token-based auth (mobile) -- only with explicit ?mode=token */
|
|
24
|
+
export function isTokenMode(url, _req) {
|
|
25
|
+
return url.searchParams.get('mode') === 'token';
|
|
26
|
+
}
|
|
27
|
+
/** Validate returnTo is a safe relative path (prevents open redirect). */
|
|
28
|
+
export function safeReturnTo(returnTo, fallback) {
|
|
29
|
+
if (!returnTo)
|
|
30
|
+
return fallback;
|
|
31
|
+
// Must start with / and must not start with // (protocol-relative URL)
|
|
32
|
+
if (returnTo.startsWith('/') && !returnTo.startsWith('//'))
|
|
33
|
+
return returnTo;
|
|
34
|
+
return fallback;
|
|
35
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export async function handleVerifyEmail(config, url, res, db) {
|
|
2
|
+
const token = url.searchParams.get('token');
|
|
3
|
+
// If no DB or no token, redirect to verify page which shows proper UI
|
|
4
|
+
if (!db || !token) {
|
|
5
|
+
res.writeHead(302, { Location: `/auth/verify${token ? '?token=' + encodeURIComponent(token) : '?error=missing'}` });
|
|
6
|
+
res.end();
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const { verifyVerificationToken, verifyUserEmail } = await import('../native-auth.js');
|
|
10
|
+
const userId = verifyVerificationToken(token, config.session.secret);
|
|
11
|
+
if (!userId) {
|
|
12
|
+
res.writeHead(302, { Location: '/auth/verify?error=invalid' });
|
|
13
|
+
res.end();
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
const verified = await verifyUserEmail(db, userId);
|
|
17
|
+
if (!verified) {
|
|
18
|
+
res.writeHead(302, { Location: '/auth/verify?error=not_found' });
|
|
19
|
+
res.end();
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
// Redirect to login page with success message
|
|
23
|
+
res.writeHead(302, { Location: `${config.routes.loginPage}?verified=true` });
|
|
24
|
+
res.end();
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'http';
|
|
2
|
+
import type { ResolvedAuthConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Handle auth routes (login, callback, logout, signup, me).
|
|
5
|
+
* Supports both OIDC and native auth.
|
|
6
|
+
* Returns true if the request was handled.
|
|
7
|
+
*/
|
|
8
|
+
export declare function handleAuthRoutes(config: ResolvedAuthConfig, req: IncomingMessage, res: ServerResponse, db?: any): Promise<boolean>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { sendJson } from './routes/utils.js';
|
|
2
|
+
import { handleNativeLogin, handleOidcLogin } from './routes/login.js';
|
|
3
|
+
import { handleNativeSignup } from './routes/signup.js';
|
|
4
|
+
import { handleOidcCallback } from './routes/oidc-callback.js';
|
|
5
|
+
import { handleLogout, handleLogoutAll } from './routes/logout.js';
|
|
6
|
+
import { handleVerifyEmail } from './routes/verify.js';
|
|
7
|
+
import { handleForgotPassword, handleResetPassword, handleChangePassword } from './routes/password.js';
|
|
8
|
+
import { handleTokenRefresh, handleTokenRevoke } from './routes/token.js';
|
|
9
|
+
import { handleTotpSetup, handleTotpVerifySetup, handleTotpDisable, handleTotpChallenge } from './routes/totp.js';
|
|
10
|
+
/**
|
|
11
|
+
* Validate Origin header on POST requests to prevent CSRF.
|
|
12
|
+
* Returns true if the request is safe to proceed.
|
|
13
|
+
*/
|
|
14
|
+
function checkOrigin(req, url) {
|
|
15
|
+
if (req.method !== 'POST')
|
|
16
|
+
return true;
|
|
17
|
+
const origin = req.headers.origin || req.headers.referer;
|
|
18
|
+
if (!origin)
|
|
19
|
+
return true; // Allow requests without Origin (non-browser clients)
|
|
20
|
+
try {
|
|
21
|
+
const originUrl = new URL(origin);
|
|
22
|
+
// Direct match
|
|
23
|
+
if (originUrl.origin === url.origin)
|
|
24
|
+
return true;
|
|
25
|
+
// Behind reverse proxy: check X-Forwarded-Host
|
|
26
|
+
const fwdHost = req.headers['x-forwarded-host']?.split(',')[0]?.trim();
|
|
27
|
+
if (fwdHost && originUrl.host === fwdHost)
|
|
28
|
+
return true;
|
|
29
|
+
// Match hostname only (ignore port differences from proxy)
|
|
30
|
+
if (originUrl.hostname === url.hostname)
|
|
31
|
+
return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Handle auth routes (login, callback, logout, signup, me).
|
|
40
|
+
* Supports both OIDC and native auth.
|
|
41
|
+
* Returns true if the request was handled.
|
|
42
|
+
*/
|
|
43
|
+
export async function handleAuthRoutes(config, req, res, db) {
|
|
44
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
45
|
+
const pathname = url.pathname;
|
|
46
|
+
const routes = config.routes;
|
|
47
|
+
// CSRF check: verify Origin header on POST requests
|
|
48
|
+
if (req.method === 'POST' && !checkOrigin(req, url)) {
|
|
49
|
+
sendJson(res, 403, { error: 'Origin mismatch — possible CSRF' });
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// ── Login (GET) — redirect to OIDC or show available methods ──
|
|
53
|
+
// /__nk_auth/login/<provider> — specific provider
|
|
54
|
+
// /__nk_auth/login — default provider or first OIDC
|
|
55
|
+
if (pathname.startsWith(routes.login)) {
|
|
56
|
+
const providerName = pathname.slice(routes.login.length + 1) || undefined; // e.g. "keycloak"
|
|
57
|
+
// Native login via POST
|
|
58
|
+
if (req.method === 'POST') {
|
|
59
|
+
return handleNativeLogin(config, req, res, url, db);
|
|
60
|
+
}
|
|
61
|
+
// OIDC login
|
|
62
|
+
return handleOidcLogin(config, res, url, providerName);
|
|
63
|
+
}
|
|
64
|
+
// ── Signup (POST) — native registration ───────────────────────
|
|
65
|
+
if (pathname === routes.signup && req.method === 'POST') {
|
|
66
|
+
return handleNativeSignup(config, req, res, db);
|
|
67
|
+
}
|
|
68
|
+
// ── Callback — OIDC code exchange ─────────────────────────────
|
|
69
|
+
if (pathname === routes.callback) {
|
|
70
|
+
return handleOidcCallback(config, req, res, url, db);
|
|
71
|
+
}
|
|
72
|
+
// ── Logout ────────────────────────────────────────────────────
|
|
73
|
+
if (pathname === routes.logout) {
|
|
74
|
+
return handleLogout(config, req, res, url, db);
|
|
75
|
+
}
|
|
76
|
+
// ── Logout All — invalidate all sessions across devices ──────
|
|
77
|
+
if (pathname === '/__nk_auth/logout-all' && req.method === 'POST') {
|
|
78
|
+
return handleLogoutAll(config, req, res, db);
|
|
79
|
+
}
|
|
80
|
+
// ── Me — return current user ──────────────────────────────────
|
|
81
|
+
if (pathname === '/__nk_auth/me') {
|
|
82
|
+
const user = req.nkAuth?.user ?? null;
|
|
83
|
+
sendJson(res, 200, user);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
// ── Verify email ───────────────────────────────────────────────
|
|
87
|
+
if (pathname === '/__nk_auth/verify-email' && req.method === 'GET') {
|
|
88
|
+
return handleVerifyEmail(config, url, res, db);
|
|
89
|
+
}
|
|
90
|
+
// ── Forgot password (request reset) ───────────────────────────
|
|
91
|
+
if (pathname === '/__nk_auth/forgot-password' && req.method === 'POST') {
|
|
92
|
+
return handleForgotPassword(config, req, res, db);
|
|
93
|
+
}
|
|
94
|
+
// ── Reset password (with token) ───────────────────────────────
|
|
95
|
+
if (pathname === '/__nk_auth/reset-password' && req.method === 'POST') {
|
|
96
|
+
return handleResetPassword(config, req, res, db);
|
|
97
|
+
}
|
|
98
|
+
// ── Change password (authenticated) ──────────────────────────
|
|
99
|
+
if (pathname === '/__nk_auth/change-password' && req.method === 'POST') {
|
|
100
|
+
return handleChangePassword(config, req, res, db);
|
|
101
|
+
}
|
|
102
|
+
// ── Refresh — exchange refresh token for new access token ─────
|
|
103
|
+
if (pathname === '/__nk_auth/refresh' && req.method === 'POST') {
|
|
104
|
+
return handleTokenRefresh(config, req, res, db);
|
|
105
|
+
}
|
|
106
|
+
// ── Revoke — invalidate refresh token (mobile logout) ─────────
|
|
107
|
+
if (pathname === '/__nk_auth/revoke' && req.method === 'POST') {
|
|
108
|
+
return handleTokenRevoke(req, res, db);
|
|
109
|
+
}
|
|
110
|
+
// ── TOTP 2FA ──────────────────────────────────────────────────
|
|
111
|
+
if (pathname === '/__nk_auth/totp/setup' && req.method === 'POST') {
|
|
112
|
+
return handleTotpSetup(config, req, res, db);
|
|
113
|
+
}
|
|
114
|
+
if (pathname === '/__nk_auth/totp/verify-setup' && req.method === 'POST') {
|
|
115
|
+
return handleTotpVerifySetup(config, req, res, db);
|
|
116
|
+
}
|
|
117
|
+
if (pathname === '/__nk_auth/totp/disable' && req.method === 'POST') {
|
|
118
|
+
return handleTotpDisable(config, req, res, db);
|
|
119
|
+
}
|
|
120
|
+
if (pathname === '/__nk_auth/totp/challenge' && req.method === 'POST') {
|
|
121
|
+
return handleTotpChallenge(config, req, res, db);
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SessionData } from './types.js';
|
|
2
|
+
export declare function deriveKey(secret: string): Buffer;
|
|
3
|
+
export declare function encryptSession(data: SessionData, secret: string): Promise<string>;
|
|
4
|
+
export declare function decryptSession(cookie: string, secret: string): Promise<SessionData | null>;
|
|
5
|
+
export declare function createSessionCookie(name: string, value: string, maxAge: number, secure: boolean): string;
|
|
6
|
+
export declare function clearSessionCookie(name: string): string;
|
|
7
|
+
export declare function parseSessionCookie(cookieHeader: string, name: string): string | undefined;
|
|
8
|
+
export declare function shouldRefreshSession(session: SessionData): boolean;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
const keyCache = new Map();
|
|
3
|
+
export function deriveKey(secret) {
|
|
4
|
+
const cached = keyCache.get(secret);
|
|
5
|
+
if (cached)
|
|
6
|
+
return cached;
|
|
7
|
+
const key = Buffer.from(crypto.hkdfSync('sha256', secret, '', 'lumenjs-session', 32));
|
|
8
|
+
keyCache.set(secret, key);
|
|
9
|
+
return key;
|
|
10
|
+
}
|
|
11
|
+
export async function encryptSession(data, secret) {
|
|
12
|
+
const key = deriveKey(secret);
|
|
13
|
+
const iv = crypto.randomBytes(12);
|
|
14
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
15
|
+
const plaintext = JSON.stringify(data);
|
|
16
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
|
17
|
+
const tag = cipher.getAuthTag();
|
|
18
|
+
return `${iv.toString('base64url')}.${ciphertext.toString('base64url')}.${tag.toString('base64url')}`;
|
|
19
|
+
}
|
|
20
|
+
export async function decryptSession(cookie, secret) {
|
|
21
|
+
try {
|
|
22
|
+
const parts = cookie.split('.');
|
|
23
|
+
if (parts.length !== 3)
|
|
24
|
+
return null;
|
|
25
|
+
const iv = Buffer.from(parts[0], 'base64url');
|
|
26
|
+
const ciphertext = Buffer.from(parts[1], 'base64url');
|
|
27
|
+
const tag = Buffer.from(parts[2], 'base64url');
|
|
28
|
+
const key = deriveKey(secret);
|
|
29
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
|
30
|
+
decipher.setAuthTag(tag);
|
|
31
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
32
|
+
return JSON.parse(plaintext.toString('utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function createSessionCookie(name, value, maxAge, secure) {
|
|
39
|
+
let cookie = `${name}=${value}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}`;
|
|
40
|
+
if (secure)
|
|
41
|
+
cookie += '; Secure';
|
|
42
|
+
return cookie;
|
|
43
|
+
}
|
|
44
|
+
export function clearSessionCookie(name) {
|
|
45
|
+
return `${name}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`;
|
|
46
|
+
}
|
|
47
|
+
export function parseSessionCookie(cookieHeader, name) {
|
|
48
|
+
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
49
|
+
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${escapedName}=([^;]*)`));
|
|
50
|
+
return match ? match[1] : undefined;
|
|
51
|
+
}
|
|
52
|
+
export function shouldRefreshSession(session) {
|
|
53
|
+
return session.expiresAt - Date.now() / 1000 < 300;
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AuthUser } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Issue a short-lived access token (HMAC-SHA256 signed, stateless).
|
|
4
|
+
* Format: base64url(payload).base64url(signature)
|
|
5
|
+
*/
|
|
6
|
+
export declare function issueAccessToken(user: AuthUser, secret: string, ttlSeconds: number): string;
|
|
7
|
+
/**
|
|
8
|
+
* Verify and decode an access token. Returns the user or null if invalid/expired.
|
|
9
|
+
*/
|
|
10
|
+
export declare function verifyAccessToken(token: string, secret: string): AuthUser | null;
|
|
11
|
+
/**
|
|
12
|
+
* Generate an opaque refresh token (random bytes, stored hashed in DB).
|
|
13
|
+
*/
|
|
14
|
+
export declare function generateRefreshToken(): string;
|
|
15
|
+
/**
|
|
16
|
+
* Hash a refresh token for DB storage (SHA-256).
|
|
17
|
+
*/
|
|
18
|
+
export declare function hashRefreshToken(token: string): string;
|
|
19
|
+
interface Db {
|
|
20
|
+
all<T = any>(sql: string, ...params: any[]): Promise<T[]>;
|
|
21
|
+
get<T = any>(sql: string, ...params: any[]): Promise<T | undefined>;
|
|
22
|
+
run(sql: string, ...params: any[]): Promise<{
|
|
23
|
+
changes: number;
|
|
24
|
+
lastInsertRowid: number | bigint;
|
|
25
|
+
}>;
|
|
26
|
+
exec(sql: string): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
export declare function ensureRefreshTokenTable(db: Db): Promise<void>;
|
|
29
|
+
export declare function storeRefreshToken(db: Db, token: string, userId: string, ttlSeconds: number): Promise<void>;
|
|
30
|
+
export declare function validateRefreshToken(db: Db, token: string): Promise<string | null>;
|
|
31
|
+
export declare function deleteRefreshToken(db: Db, token: string): Promise<void>;
|
|
32
|
+
export declare function deleteAllRefreshTokens(db: Db, userId: string): Promise<void>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Issue a short-lived access token (HMAC-SHA256 signed, stateless).
|
|
4
|
+
* Format: base64url(payload).base64url(signature)
|
|
5
|
+
*/
|
|
6
|
+
export function issueAccessToken(user, secret, ttlSeconds) {
|
|
7
|
+
const payload = {
|
|
8
|
+
sub: user.sub,
|
|
9
|
+
email: user.email,
|
|
10
|
+
name: user.name,
|
|
11
|
+
roles: user.roles || [],
|
|
12
|
+
provider: user.provider,
|
|
13
|
+
iat: Math.floor(Date.now() / 1000),
|
|
14
|
+
exp: Math.floor(Date.now() / 1000) + ttlSeconds,
|
|
15
|
+
};
|
|
16
|
+
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
17
|
+
const signature = crypto.createHmac('sha256', secret).update(payloadB64).digest('base64url');
|
|
18
|
+
return `${payloadB64}.${signature}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Verify and decode an access token. Returns the user or null if invalid/expired.
|
|
22
|
+
*/
|
|
23
|
+
export function verifyAccessToken(token, secret) {
|
|
24
|
+
try {
|
|
25
|
+
const parts = token.split('.');
|
|
26
|
+
if (parts.length !== 2)
|
|
27
|
+
return null;
|
|
28
|
+
const [payloadB64, signature] = parts;
|
|
29
|
+
const expectedSig = crypto.createHmac('sha256', secret).update(payloadB64).digest('base64url');
|
|
30
|
+
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSig)))
|
|
31
|
+
return null;
|
|
32
|
+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
|
|
33
|
+
if (payload.exp < Math.floor(Date.now() / 1000))
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
sub: payload.sub,
|
|
37
|
+
email: payload.email,
|
|
38
|
+
name: payload.name,
|
|
39
|
+
roles: payload.roles || [],
|
|
40
|
+
provider: payload.provider,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Generate an opaque refresh token (random bytes, stored hashed in DB).
|
|
49
|
+
*/
|
|
50
|
+
export function generateRefreshToken() {
|
|
51
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Hash a refresh token for DB storage (SHA-256).
|
|
55
|
+
*/
|
|
56
|
+
export function hashRefreshToken(token) {
|
|
57
|
+
return crypto.createHash('sha256').update(token).digest('hex');
|
|
58
|
+
}
|
|
59
|
+
export async function ensureRefreshTokenTable(db) {
|
|
60
|
+
await db.exec(`CREATE TABLE IF NOT EXISTS _nk_auth_refresh_tokens (
|
|
61
|
+
id SERIAL PRIMARY KEY,
|
|
62
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
63
|
+
user_id TEXT NOT NULL,
|
|
64
|
+
expires_at TEXT NOT NULL,
|
|
65
|
+
created_at TEXT NOT NULL DEFAULT NOW()
|
|
66
|
+
)`);
|
|
67
|
+
}
|
|
68
|
+
export async function storeRefreshToken(db, token, userId, ttlSeconds) {
|
|
69
|
+
const tokenHash = hashRefreshToken(token);
|
|
70
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
71
|
+
await db.run('INSERT INTO _nk_auth_refresh_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)', tokenHash, userId, expiresAt);
|
|
72
|
+
}
|
|
73
|
+
export async function validateRefreshToken(db, token) {
|
|
74
|
+
const tokenHash = hashRefreshToken(token);
|
|
75
|
+
const row = await db.get('SELECT user_id, expires_at FROM _nk_auth_refresh_tokens WHERE token_hash = ?', tokenHash);
|
|
76
|
+
if (!row)
|
|
77
|
+
return null;
|
|
78
|
+
if (new Date(row.expires_at) < new Date()) {
|
|
79
|
+
await db.run('DELETE FROM _nk_auth_refresh_tokens WHERE token_hash = ?', tokenHash);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return row.user_id;
|
|
83
|
+
}
|
|
84
|
+
export async function deleteRefreshToken(db, token) {
|
|
85
|
+
const tokenHash = hashRefreshToken(token);
|
|
86
|
+
await db.run('DELETE FROM _nk_auth_refresh_tokens WHERE token_hash = ?', tokenHash);
|
|
87
|
+
}
|
|
88
|
+
export async function deleteAllRefreshTokens(db, userId) {
|
|
89
|
+
await db.run('DELETE FROM _nk_auth_refresh_tokens WHERE user_id = ?', userId);
|
|
90
|
+
}
|