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/README.md +97 -34
- package/app.js +541 -85
- package/backend/server.js +346 -23
- package/frontend/public/app.js +541 -85
- package/frontend/public/autologin.html +40 -0
- package/frontend/public/index.html +29 -6
- package/frontend/public/styles.css +66 -0
- package/frontend/public/supabase-config.js +4 -11
- package/package.json +5 -1
- package/scripts/auth-url-check.mjs +166 -0
- package/scripts/create-url-login-token.mjs +52 -0
- package/scripts/manage-donor-users.mjs +229 -0
- package/scripts/sql/grant-donor.sql +22 -0
- package/scripts/sql/revoke-donor.sql +19 -0
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
|
|
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((
|
|
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');
|