mantenimento-app 2.2.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/backend/server.js CHANGED
@@ -17,13 +17,250 @@ const port = Number(process.env.PORT || 3000);
17
17
  const calculateRateWindowMs = Number(process.env.CALCULATE_RATE_WINDOW_MS || 60_000);
18
18
  const calculateRateMaxRequests = Number(process.env.CALCULATE_RATE_MAX_REQUESTS || 30);
19
19
  const calculateRequestLog = new Map();
20
+ const urlLoginTokenStore = new Map();
20
21
  const accessLogEnabled = String(process.env.ACCESS_LOG_ENABLED || (process.env.NODE_ENV === 'production' ? 'true' : 'false')).toLowerCase() === 'true';
21
22
  const accessLogSalt = String(process.env.ACCESS_LOG_SALT || 'mantenimento-app');
22
- const corsAllowedOrigins = String(process.env.CORS_ALLOWED_ORIGINS || '')
23
+ const authUrlLoginSecret = String(process.env.AUTH_URL_LOGIN_SECRET || '').trim();
24
+ const authUrlLoginAllowedUsers = new Set(
25
+ String(process.env.AUTH_URL_LOGIN_ALLOWED_USER || 'favagit')
26
+ .split(',')
27
+ .map((v) => v.trim().toLowerCase())
28
+ .filter(Boolean)
29
+ );
30
+ const authUrlLoginDefaultUser = authUrlLoginAllowedUsers.values().next().value || 'favagit';
31
+ const authUrlLoginSupabaseUrl = String(process.env.AUTH_URL_LOGIN_SUPABASE_URL || process.env.KEYLOCK_SUPABASE_URL || '').trim().replace(/\/+$/, '');
32
+ const authUrlLoginSupabaseAnonKey = String(process.env.AUTH_URL_LOGIN_SUPABASE_ANON_KEY || process.env.KEYLOCK_SUPABASE_ANON_KEY || '').trim();
33
+ const authUrlLoginSupabaseServiceRoleKey = String(process.env.AUTH_URL_LOGIN_SUPABASE_SERVICE_ROLE_KEY || '').trim();
34
+ const authUrlLoginSupabaseEmail = String(process.env.AUTH_URL_LOGIN_SUPABASE_EMAIL || '').trim().toLowerCase();
35
+ const authUrlLoginSupabasePassword = String(process.env.AUTH_URL_LOGIN_SUPABASE_PASSWORD || '');
36
+ const authUrlLoginSupabaseUsersRaw = String(process.env.AUTH_URL_LOGIN_SUPABASE_USERS_JSON || '').trim();
37
+ const authUrlLoginMaxTtlSec = Number(process.env.AUTH_URL_LOGIN_MAX_TTL_SEC || 180);
38
+ const authUrlLoginBootstrapKey = String(process.env.AUTH_URL_LOGIN_BOOTSTRAP_KEY || '').trim();
39
+ const authUrlLoginFrontendBase = String(process.env.AUTH_URL_LOGIN_FRONTEND_BASE || 'https://favagit.github.io/mantenimento-app/autologin.html').trim();
40
+ const apiAllowedOrigins = String(process.env.API_ALLOWED_ORIGINS || '')
23
41
  .split(',')
24
- .map((value) => value.trim())
42
+ .map((v) => v.trim())
25
43
  .filter(Boolean);
26
44
 
45
+ function parseSupabaseUserMap(rawValue) {
46
+ if (!rawValue) return {};
47
+ try {
48
+ const parsed = JSON.parse(rawValue);
49
+ if (!parsed || typeof parsed !== 'object') return {};
50
+ const out = {};
51
+ Object.entries(parsed).forEach(([rawUser, creds]) => {
52
+ const username = String(rawUser || '').trim().toLowerCase();
53
+ if (!username || !creds || typeof creds !== 'object') return;
54
+ const email = String(creds.email || '').trim().toLowerCase();
55
+ const password = String(creds.password || '');
56
+ if (!password) return;
57
+ out[username] = { email, password };
58
+ });
59
+ return out;
60
+ } catch (_) {
61
+ return {};
62
+ }
63
+ }
64
+
65
+ const authUrlLoginSupabaseUsersMap = parseSupabaseUserMap(authUrlLoginSupabaseUsersRaw);
66
+ const authUrlLoginSupabaseEmailCache = new Map();
67
+
68
+ function isAllowedApiOrigin(origin) {
69
+ if (!origin) return false;
70
+ if (!apiAllowedOrigins.length) return false;
71
+ return apiAllowedOrigins.includes(origin);
72
+ }
73
+
74
+ function toBase64Url(input) {
75
+ return Buffer.from(input)
76
+ .toString('base64')
77
+ .replace(/\+/g, '-')
78
+ .replace(/\//g, '_')
79
+ .replace(/=+$/g, '');
80
+ }
81
+
82
+ function fromBase64Url(input) {
83
+ const normalized = String(input || '')
84
+ .replace(/-/g, '+')
85
+ .replace(/_/g, '/');
86
+ const padLength = (4 - (normalized.length % 4)) % 4;
87
+ return Buffer.from(normalized + '='.repeat(padLength), 'base64');
88
+ }
89
+
90
+ function safeCompareHex(aHex, bHex) {
91
+ try {
92
+ const a = Buffer.from(String(aHex || ''), 'hex');
93
+ const b = Buffer.from(String(bHex || ''), 'hex');
94
+ if (a.length === 0 || b.length === 0 || a.length !== b.length) return false;
95
+ return crypto.timingSafeEqual(a, b);
96
+ } catch (_) {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ function safeCompareText(aText, bText) {
102
+ const a = Buffer.from(String(aText || ''), 'utf8');
103
+ const b = Buffer.from(String(bText || ''), 'utf8');
104
+ if (!a.length || !b.length || a.length !== b.length) return false;
105
+ return crypto.timingSafeEqual(a, b);
106
+ }
107
+
108
+ function pruneConsumedUrlLoginTokens(nowSec) {
109
+ for (const [jti, exp] of urlLoginTokenStore.entries()) {
110
+ if (!Number.isFinite(exp) || exp <= nowSec) {
111
+ urlLoginTokenStore.delete(jti);
112
+ }
113
+ }
114
+ }
115
+
116
+ function validateUrlLoginToken(tokenValue) {
117
+ if (!authUrlLoginSecret) return { ok: false, error: 'URL login secret missing on server.' };
118
+ if (!tokenValue || typeof tokenValue !== 'string') return { ok: false, error: 'Token missing.' };
119
+
120
+ const token = String(tokenValue || '').trim();
121
+ const parts = token.split('.');
122
+ if (parts.length !== 2) return { ok: false, error: 'Malformed token.' };
123
+
124
+ const payloadPart = parts[0];
125
+ const signaturePart = parts[1];
126
+ const expectedSignatureHex = crypto.createHmac('sha256', authUrlLoginSecret).update(payloadPart).digest('hex');
127
+ const gotSignatureHex = fromBase64Url(signaturePart).toString('hex');
128
+ if (!safeCompareHex(expectedSignatureHex, gotSignatureHex)) {
129
+ return { ok: false, error: 'Invalid token signature.' };
130
+ }
131
+
132
+ let payload;
133
+ try {
134
+ payload = JSON.parse(fromBase64Url(payloadPart).toString('utf8'));
135
+ } catch (_) {
136
+ return { ok: false, error: 'Invalid token payload.' };
137
+ }
138
+
139
+ const nowSec = Math.floor(Date.now() / 1000);
140
+ pruneConsumedUrlLoginTokens(nowSec);
141
+
142
+ const sub = String(payload && payload.sub ? payload.sub : '').trim().toLowerCase();
143
+ const aud = String(payload && payload.aud ? payload.aud : '').trim().toLowerCase();
144
+ const jti = String(payload && payload.jti ? payload.jti : '').trim();
145
+ const exp = Number(payload && payload.exp);
146
+ const iat = Number(payload && payload.iat);
147
+
148
+ if (!sub || !authUrlLoginAllowedUsers.has(sub)) return { ok: false, error: 'Token user not allowed.' };
149
+ if (aud && aud !== 'url-login') return { ok: false, error: 'Invalid token audience.' };
150
+ if (!jti) return { ok: false, error: 'Token nonce missing.' };
151
+ if (!Number.isFinite(exp) || exp <= nowSec) return { ok: false, error: 'Token expired.' };
152
+ if (!Number.isFinite(iat) || iat > nowSec + 30) return { ok: false, error: 'Invalid token issue time.' };
153
+ if ((exp - iat) > authUrlLoginMaxTtlSec) return { ok: false, error: 'Token TTL too long.' };
154
+ if (urlLoginTokenStore.has(jti)) return { ok: false, error: 'Token already used.' };
155
+
156
+ urlLoginTokenStore.set(jti, exp);
157
+ return { ok: true, payload };
158
+ }
159
+
160
+ async function findSupabaseEmailBySubject(subject) {
161
+ const normalizedSubject = String(subject || '').trim().toLowerCase();
162
+ if (!normalizedSubject) return '';
163
+ if (authUrlLoginSupabaseEmailCache.has(normalizedSubject)) {
164
+ return authUrlLoginSupabaseEmailCache.get(normalizedSubject) || '';
165
+ }
166
+ if (!authUrlLoginSupabaseServiceRoleKey || !authUrlLoginSupabaseUrl) return '';
167
+
168
+ let page = 1;
169
+ while (page <= 20) {
170
+ const response = await fetch(`${authUrlLoginSupabaseUrl}/auth/v1/admin/users?page=${page}&per_page=200`, {
171
+ headers: {
172
+ apikey: authUrlLoginSupabaseServiceRoleKey,
173
+ Authorization: `Bearer ${authUrlLoginSupabaseServiceRoleKey}`
174
+ }
175
+ });
176
+ const payload = await response.json().catch(() => ({}));
177
+ if (!response.ok) return '';
178
+
179
+ const users = Array.isArray(payload && payload.users) ? payload.users : [];
180
+ if (!users.length) break;
181
+
182
+ const found = users.find((user) => {
183
+ const email = String(user && user.email ? user.email : '').trim().toLowerCase();
184
+ const localPart = email.split('@')[0] || '';
185
+ return localPart === normalizedSubject;
186
+ });
187
+
188
+ if (found && found.email) {
189
+ const email = String(found.email).trim().toLowerCase();
190
+ authUrlLoginSupabaseEmailCache.set(normalizedSubject, email);
191
+ return email;
192
+ }
193
+
194
+ if (users.length < 200) break;
195
+ page += 1;
196
+ }
197
+
198
+ return '';
199
+ }
200
+
201
+ async function resolveSupabaseCredentialsForSubject(subject) {
202
+ const normalizedSubject = String(subject || '').trim().toLowerCase();
203
+ const mapped = authUrlLoginSupabaseUsersMap[normalizedSubject];
204
+ if (mapped && mapped.password) {
205
+ if (mapped.email) {
206
+ return { email: mapped.email, password: mapped.password };
207
+ }
208
+
209
+ const discoveredEmail = await findSupabaseEmailBySubject(normalizedSubject);
210
+ if (discoveredEmail) {
211
+ return { email: discoveredEmail, password: mapped.password };
212
+ }
213
+ }
214
+
215
+ if (authUrlLoginSupabaseEmail && authUrlLoginSupabasePassword) {
216
+ return { email: authUrlLoginSupabaseEmail, password: authUrlLoginSupabasePassword };
217
+ }
218
+
219
+ return { email: '', password: '' };
220
+ }
221
+
222
+ function createUrlLoginToken(subject, ttlSeconds) {
223
+ const nowSec = Math.floor(Date.now() / 1000);
224
+ const ttl = Number.isFinite(ttlSeconds)
225
+ ? Math.max(30, Math.min(authUrlLoginMaxTtlSec, Math.floor(ttlSeconds)))
226
+ : Math.min(120, authUrlLoginMaxTtlSec);
227
+
228
+ const payload = {
229
+ sub: String(subject || '').trim().toLowerCase(),
230
+ aud: 'url-login',
231
+ iat: nowSec,
232
+ exp: nowSec + ttl,
233
+ jti: typeof crypto.randomUUID === 'function'
234
+ ? crypto.randomUUID()
235
+ : crypto.randomBytes(16).toString('hex')
236
+ };
237
+
238
+ const payloadPart = toBase64Url(JSON.stringify(payload));
239
+ const signatureHex = crypto.createHmac('sha256', authUrlLoginSecret).update(payloadPart).digest('hex');
240
+ const signaturePart = toBase64Url(Buffer.from(signatureHex, 'hex'));
241
+ return `${payloadPart}.${signaturePart}`;
242
+ }
243
+
244
+ function buildFrontendAutologinUrl(token) {
245
+ const base = authUrlLoginFrontendBase || 'https://favagit.github.io/mantenimento-app/autologin.html';
246
+ const joiner = base.includes('?') ? '&' : '?';
247
+ return `${base}${joiner}autologin=1&authToken=${encodeURIComponent(String(token || '').trim())}`;
248
+ }
249
+
250
+ async function deriveProfileKeyForUser(userId, passwordSeed) {
251
+ const password = String(passwordSeed || '');
252
+ if (!password || !userId) return '';
253
+
254
+ const salt = crypto.createHash('sha256').update(`keylock:${userId}`).digest();
255
+ const key = await new Promise((resolve, reject) => {
256
+ crypto.pbkdf2(password, salt, 120000, 32, 'sha256', (err, derivedKey) => {
257
+ if (err) return reject(err);
258
+ resolve(derivedKey);
259
+ });
260
+ });
261
+ return Buffer.from(key).toString('base64');
262
+ }
263
+
27
264
  const publicDir = path.join(__dirname, '..', 'frontend', 'public');
28
265
  // When DEV_SOURCE_JS=true the server always serves raw app.js (no rebuild needed).
29
266
  const devSourceJs = String(process.env.DEV_SOURCE_JS || 'false').toLowerCase() === 'true';
@@ -83,27 +320,6 @@ function applyCalculateRateLimit(req, res, next) {
83
320
 
84
321
  app.set('trust proxy', 1);
85
322
  app.disable('x-powered-by');
86
- app.use((req, res, next) => {
87
- const origin = String(req.headers.origin || '').trim();
88
- const isAllowedOrigin = origin && corsAllowedOrigins.includes(origin);
89
-
90
- if (isAllowedOrigin) {
91
- res.setHeader('Access-Control-Allow-Origin', origin);
92
- res.setHeader('Vary', 'Origin');
93
- res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
94
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type,X-Request-Id');
95
- res.setHeader('Access-Control-Max-Age', '600');
96
- }
97
-
98
- if (req.method === 'OPTIONS') {
99
- if (isAllowedOrigin) {
100
- return res.status(204).end();
101
- }
102
- return res.status(403).json({ ok: false, error: 'Origin not allowed.' });
103
- }
104
-
105
- return next();
106
- });
107
323
  app.use(express.json({ limit: '64kb' }));
108
324
  app.use((req, res, next) => {
109
325
  const startedAt = process.hrtime.bigint();
@@ -161,6 +377,22 @@ app.use((err, req, res, next) => {
161
377
  return next(err);
162
378
  });
163
379
 
380
+ app.use('/api', (req, res, next) => {
381
+ const origin = String(req.get('origin') || '');
382
+ if (isAllowedApiOrigin(origin)) {
383
+ res.setHeader('Access-Control-Allow-Origin', origin);
384
+ res.setHeader('Vary', 'Origin');
385
+ res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS');
386
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
387
+ res.setHeader('Access-Control-Max-Age', '600');
388
+ }
389
+
390
+ if (req.method === 'OPTIONS') {
391
+ return res.sendStatus(204);
392
+ }
393
+ return next();
394
+ });
395
+
164
396
  // Serve minified bundle when available, fallback to source app.js in development.
165
397
  app.get('/app.js', (req, res) => {
166
398
  const minPath = path.join(publicDir, 'app.min.js');
@@ -169,6 +401,97 @@ app.get('/app.js', (req, res) => {
169
401
  res.sendFile(target);
170
402
  });
171
403
 
404
+ app.post('/api/auth/url-login/exchange', async (req, res) => {
405
+ res.setHeader('Cache-Control', 'no-store, max-age=0');
406
+ res.setHeader('Pragma', 'no-cache');
407
+
408
+ const hasServerConfig = authUrlLoginSecret
409
+ && authUrlLoginSupabaseUrl
410
+ && authUrlLoginSupabaseAnonKey;
411
+
412
+ if (!hasServerConfig) {
413
+ return res.status(503).json({ ok: false, error: 'Secure URL login not configured on server.' });
414
+ }
415
+
416
+ const token = String(req.body && req.body.token ? req.body.token : '').trim();
417
+ const tokenCheck = validateUrlLoginToken(token);
418
+ if (!tokenCheck.ok) {
419
+ return res.status(401).json({ ok: false, error: tokenCheck.error || 'Invalid token.' });
420
+ }
421
+
422
+ const tokenSub = String(tokenCheck.payload && tokenCheck.payload.sub ? tokenCheck.payload.sub : '').trim().toLowerCase();
423
+ const creds = await resolveSupabaseCredentialsForSubject(tokenSub);
424
+ if (!creds.email || !creds.password) {
425
+ return res.status(503).json({ ok: false, error: 'Secure URL login credentials not configured for user.' });
426
+ }
427
+
428
+ try {
429
+ const supaResponse = await fetch(`${authUrlLoginSupabaseUrl}/auth/v1/token?grant_type=password`, {
430
+ method: 'POST',
431
+ headers: {
432
+ 'Content-Type': 'application/json',
433
+ apikey: authUrlLoginSupabaseAnonKey
434
+ },
435
+ body: JSON.stringify({
436
+ email: creds.email,
437
+ password: creds.password
438
+ })
439
+ });
440
+
441
+ const supaJson = await supaResponse.json().catch(() => ({}));
442
+ if (!supaResponse.ok || !supaJson || !supaJson.access_token || !supaJson.refresh_token || !supaJson.user || !supaJson.user.id) {
443
+ return res.status(401).json({ ok: false, error: 'Supabase session exchange failed.' });
444
+ }
445
+
446
+ const profileKey = await deriveProfileKeyForUser(String(supaJson.user.id), creds.password);
447
+ return res.json({
448
+ ok: true,
449
+ session: {
450
+ access_token: supaJson.access_token,
451
+ refresh_token: supaJson.refresh_token,
452
+ expires_in: Number(supaJson.expires_in || 0),
453
+ token_type: String(supaJson.token_type || 'bearer'),
454
+ user: supaJson.user,
455
+ profile_key: profileKey
456
+ }
457
+ });
458
+ } catch (_) {
459
+ return res.status(500).json({ ok: false, error: 'Secure URL login exchange error.' });
460
+ }
461
+ });
462
+
463
+ app.get('/api/auth/url-login/start', (req, res) => {
464
+ res.setHeader('Cache-Control', 'no-store, max-age=0');
465
+ res.setHeader('Pragma', 'no-cache');
466
+
467
+ if (!authUrlLoginSecret || !authUrlLoginBootstrapKey) {
468
+ return res.status(503).json({ ok: false, error: 'Secure URL login bootstrap not configured on server.' });
469
+ }
470
+
471
+ const bootstrapKeyInput = String(req.get('x-url-login-key') || req.query.k || '').trim();
472
+ if (!safeCompareText(authUrlLoginBootstrapKey, bootstrapKeyInput)) {
473
+ return res.status(401).json({ ok: false, error: 'Invalid bootstrap key.' });
474
+ }
475
+
476
+ const requestedSub = String(req.query.sub || authUrlLoginDefaultUser).trim().toLowerCase();
477
+ if (!requestedSub || !authUrlLoginAllowedUsers.has(requestedSub)) {
478
+ return res.status(400).json({ ok: false, error: 'Unsupported bootstrap subject.' });
479
+ }
480
+
481
+ const ttlRaw = Number(req.query.ttl);
482
+ const ttl = Number.isFinite(ttlRaw) ? ttlRaw : Math.min(120, authUrlLoginMaxTtlSec);
483
+
484
+ const token = createUrlLoginToken(requestedSub, ttl);
485
+ const url = buildFrontendAutologinUrl(token);
486
+ const asJson = String(req.query.format || '').trim().toLowerCase() === 'json';
487
+
488
+ if (asJson) {
489
+ return res.json({ ok: true, url });
490
+ }
491
+
492
+ return res.redirect(302, url);
493
+ });
494
+
172
495
  app.post('/api/calculate', applyCalculateRateLimit, (req, res) => {
173
496
  res.setHeader('Cache-Control', 'no-store, max-age=0');
174
497
  res.setHeader('Pragma', 'no-cache');