mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
2d0414b4a3
Fixes the critical + high + medium findings from our internal security
review. Bundled into one PR because the changes overlap heavily (JWT
verification unifies across three call sites; backup-code hashing and
demo-email handling cross-cut several services); splitting them out
would mean redundant reviews of the same files.
Critical
- CI-C1 — .github/workflows/test.yml: restore actions/{checkout,setup-
node,upload-artifact} to @v4. The @v6 refs don't exist, so the test
workflow was errorring before a single test ran.
- SEC-C1 — mfaPolicy now extracts the token via extractToken() (cookie-
first, Bearer fallback). Previously it only read Authorization, so
every cookie-authenticated SPA session bypassed require_mfa entirely.
- SEC-C2/C4/C6 — all JWT verification paths (MCP bearer, file download,
photo route) now go through the shared verifyJwtAndLoadUser that
checks password_version. resetPassword additionally deletes every
mcp_tokens row and marks outstanding oauth_tokens revoked, so a
password reset invalidates ALL credential classes — not just the
cookie JWT.
High
- SEC-H2 — reset email URL is built from server-side APP_URL /
ALLOWED_ORIGINS (via existing getAppUrl()), not request headers.
Closes the host-header-injection vector into reset links.
- SEC-H3 — OIDC findOrCreateUser wraps the invite-redemption UPDATE +
user INSERT in a transaction. The UPDATE is the capacity check; if
a concurrent callback takes the last slot, the whole transaction
aborts with registration_disabled instead of double-creating users.
- SEC-H4 — new verifyIdToken() performs full JWT signature
verification via the provider's JWKS (Node's crypto.createPublicKey
accepts JWK directly — no extra dependency), plus iss/aud/exp
checks. The callback also rejects the login when userinfo.sub does
not match id_token.sub.
- SEC-H5 — OAuth DCR now validates redirect_uris against an allowlist
of schemes: https, http-loopback, or a private custom scheme. Plain
http://non-loopback is rejected.
- SEC-H6 — oauthService audience defaults to mcpResource when the
`resource` parameter is missing, so tokens are always audience-bound
to /mcp instead of being issued with audience=null.
- SEC-H7 — HSTS is enabled any time NODE_ENV=production (previously
required FORCE_HTTPS=true), includeSubDomains defaults on and can
be disabled with HSTS_INCLUDE_SUBDOMAINS=false.
- SEC-H8 — trek_session cookie Secure flag is also driven by
req.secure (which Express resolves from X-Forwarded-Proto once
trust proxy is set), so instances behind a TLS-terminating proxy
get Secure cookies without needing FORCE_HTTPS.
Medium
- SEC-M1 — permanentDeleteFile / emptyTrash / avatar unlink now use
fs.promises.rm with { force: true } (one async op vs the previous
existsSync + unlinkSync pair per file).
- SEC-M2 — invalidatePermissionsCache() is called inside restoreFromZip
so a restored DB with different permission rows is honoured
immediately.
- SEC-M3 + C1 — idempotency store bounds the key at 128 chars, caches
only responses ≤ 256 KiB, and scopes the lookup by (key, user_id,
method, path) rather than (key, user_id). Same key replayed against
a different endpoint no longer returns a stale unrelated body.
- SEC-M4 — share_tokens gets an expires_at column; new tokens default
to 90-day TTL, expired tokens are denied at lookup. Existing tokens
stay NULL = no expiry so already-published links don't break.
- SEC-M5 — /uploads/photos/:filename now resolves the photo to its
trip_id and requires the share token to cover THAT trip. Previously
any share token for any trip would unlock any photo filename.
- SEC-M6 — BLOCKED_EXTENSIONS is the single source of truth shared
between fileService and collab uploads. The '*' allowed_file_types
wildcard now still rejects executables/scripts.
- SEC-M7 — single DEMO_EMAILS constant (services/demo.ts) used by
demoUploadBlock, mfaPolicy, and every demo-mode guard in
authService. The old demoUploadBlock only matched 'demo@nomad.app'
so the seed 'demo@trek.app' could in fact upload in demo mode.
- SEC-M8 — MFA backup codes are now bcrypt-hashed at rest
(hashBackupCodeBcrypt). matchBackupCode accepts both bcrypt and
legacy SHA-256 hex hashes, so existing installs keep working until
the user regenerates codes via enableMfa.
- SEC-M9 — document the "security via UUID v4 filename" model for
/uploads/avatars|covers|journey. Requires no code change but
captures the decision so future reviewers don't re-flag it.
- SEC-M10 — already covered by the resetPassword revocation logic
above: mcp_tokens DELETE + oauth_tokens UPDATE … SET revoked_at.
Performance
- PERF-H1 — new migration adds the indexes flagged in the audit:
trips(user_id), trips(created_at DESC), photos(day_id),
photos(place_id), reservations(day_id), share_tokens(token), plus
conditional day_accommodations and notifications indexes depending
on which columns are present.
Tests
- tests/integration/oidc.test.ts now mocks verifyIdToken and passes
an id_token in the exchangeCodeForToken stub for the three flows
that exercise a successful callback. The three remaining failures
tests pointed out were all pre-existing (file-upload flakes +
notificationPreferences event_types count drift), none introduced
by this PR.
408 lines
17 KiB
TypeScript
408 lines
17 KiB
TypeScript
import express, { Request, Response, NextFunction } from 'express';
|
|
import multer from 'multer';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { v4 as uuid } from 'uuid';
|
|
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
|
|
import { AuthRequest, OptionalAuthRequest } from '../types';
|
|
import { writeAudit, getClientIp } from '../services/auditLog';
|
|
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
|
|
import {
|
|
getAppConfig,
|
|
demoLogin,
|
|
validateInviteToken,
|
|
registerUser,
|
|
loginUser,
|
|
getCurrentUser,
|
|
changePassword,
|
|
deleteAccount,
|
|
updateMapsKey,
|
|
updateApiKeys,
|
|
updateSettings,
|
|
getSettings,
|
|
saveAvatar,
|
|
deleteAvatar,
|
|
listUsers,
|
|
validateKeys,
|
|
getAppSettings,
|
|
updateAppSettings,
|
|
getTravelStats,
|
|
setupMfa,
|
|
enableMfa,
|
|
disableMfa,
|
|
verifyMfaLogin,
|
|
listMcpTokens,
|
|
createMcpToken,
|
|
deleteMcpToken,
|
|
createWsToken,
|
|
createResourceToken,
|
|
requestPasswordReset,
|
|
resetPassword,
|
|
} from '../services/authService';
|
|
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
|
|
|
|
const router = express.Router();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Avatar upload (multer config stays in route — middleware concern)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
|
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
|
|
|
|
const avatarStorage = multer.diskStorage({
|
|
destination: (_req, _file, cb) => cb(null, avatarDir),
|
|
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)),
|
|
});
|
|
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
|
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
|
const avatarUpload = multer({
|
|
storage: avatarStorage,
|
|
limits: { fileSize: MAX_AVATAR_SIZE },
|
|
fileFilter: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
|
|
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
|
|
err.statusCode = 400;
|
|
return cb(err);
|
|
}
|
|
cb(null, true);
|
|
},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rate limiter (middleware concern — stays in route)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
|
|
const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
|
|
|
|
const loginAttempts = new Map<string, { count: number; first: number }>();
|
|
const mfaAttempts = new Map<string, { count: number; first: number }>();
|
|
const forgotAttempts = new Map<string, { count: number; first: number }>();
|
|
const resetAttempts = new Map<string, { count: number; first: number }>();
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, record] of loginAttempts) {
|
|
if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key);
|
|
}
|
|
for (const [key, record] of mfaAttempts) {
|
|
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
|
|
}
|
|
for (const [key, record] of forgotAttempts) {
|
|
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
|
|
}
|
|
for (const [key, record] of resetAttempts) {
|
|
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
|
|
}
|
|
}, RATE_LIMIT_CLEANUP);
|
|
|
|
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
const key = req.ip || 'unknown';
|
|
const now = Date.now();
|
|
const record = store.get(key);
|
|
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
|
|
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
|
|
}
|
|
if (!record || now - record.first >= windowMs) {
|
|
store.set(key, { count: 1, first: now });
|
|
} else {
|
|
record.count++;
|
|
}
|
|
next();
|
|
};
|
|
}
|
|
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
|
|
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
|
|
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
|
|
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Routes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
|
|
const user = (req as OptionalAuthRequest).user;
|
|
res.json(getAppConfig(user));
|
|
});
|
|
|
|
router.post('/demo-login', (req: Request, res: Response) => {
|
|
const result = demoLogin();
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
setAuthCookie(res, result.token!, req);
|
|
res.json({ token: result.token, user: result.user });
|
|
});
|
|
|
|
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
|
|
const result = validateInviteToken(req.params.token);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at });
|
|
});
|
|
|
|
router.post('/register', authLimiter, (req: Request, res: Response) => {
|
|
const result = registerUser(req.body);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
|
|
setAuthCookie(res, result.token!, req);
|
|
res.status(201).json({ token: result.token, user: result.user });
|
|
});
|
|
|
|
router.post('/login', authLimiter, (req: Request, res: Response) => {
|
|
const result = loginUser(req.body);
|
|
if (result.auditAction) {
|
|
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
|
|
}
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
|
|
setAuthCookie(res, result.token!, req);
|
|
res.json({ token: result.token, user: result.user });
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Password reset (forgot / complete)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Generic OK response — identical regardless of email existence, to
|
|
// prevent enumeration via response body OR status code.
|
|
const GENERIC_FORGOT_RESPONSE = { ok: true };
|
|
// Minimum time we spend inside the forgot handler so a "no such user"
|
|
// path does not complete noticeably faster than a real reset.
|
|
const FORGOT_MIN_LATENCY_MS = 350;
|
|
|
|
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
|
|
const started = Date.now();
|
|
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
|
|
const ip = getClientIp(req);
|
|
|
|
const outcome = requestPasswordReset(rawEmail, ip);
|
|
|
|
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
|
|
// Build the reset URL from the server-side canonical APP_URL (or
|
|
// first ALLOWED_ORIGINS entry) — never from request headers. A
|
|
// crafted `Origin` / `Host` / `Referer` would otherwise put an
|
|
// attacker-controlled domain into the emailed reset link while the
|
|
// token itself is still legitimate.
|
|
const origin = getAppUrl();
|
|
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
|
|
|
|
// Audit the REQUEST always — even for "no user" — so abuse is visible.
|
|
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
|
|
|
|
try {
|
|
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
|
|
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
|
|
} catch (err) {
|
|
// Never surface delivery failure to the caller — still respond ok.
|
|
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
|
|
}
|
|
} else {
|
|
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
|
|
}
|
|
|
|
// Pad the response so timing doesn't reveal outcome.
|
|
const elapsed = Date.now() - started;
|
|
if (elapsed < FORGOT_MIN_LATENCY_MS) {
|
|
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
|
|
}
|
|
res.json(GENERIC_FORGOT_RESPONSE);
|
|
});
|
|
|
|
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
|
|
const ip = getClientIp(req);
|
|
const result = resetPassword(req.body);
|
|
if (result.error) {
|
|
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
|
|
return res.status(result.status!).json({ error: result.error });
|
|
}
|
|
if (result.mfa_required) {
|
|
return res.status(200).json({ mfa_required: true });
|
|
}
|
|
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
|
|
// Purposefully do NOT auto-login — the user just demonstrated they
|
|
// have email+password access; asking them to sign in fresh is the
|
|
// standard, safer UX.
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.get('/me', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const user = getCurrentUser(authReq.user.id);
|
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
|
res.json({ user });
|
|
});
|
|
|
|
router.post('/logout', (req: Request, res: Response) => {
|
|
clearAuthCookie(res, req);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = changePassword(authReq.user.id, authReq.user.email, req.body);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.delete('/me', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key));
|
|
});
|
|
|
|
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json(updateApiKeys(authReq.user.id, req.body));
|
|
});
|
|
|
|
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = updateSettings(authReq.user.id, req.body);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ success: result.success, user: result.user });
|
|
});
|
|
|
|
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = getSettings(authReq.user.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ settings: result.settings });
|
|
});
|
|
|
|
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
|
|
res.json(await saveAvatar(authReq.user.id, req.file.filename));
|
|
});
|
|
|
|
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json(await deleteAvatar(authReq.user.id));
|
|
});
|
|
|
|
router.get('/users', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json({ users: listUsers(authReq.user.id) });
|
|
});
|
|
|
|
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = await validateKeys(authReq.user.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details });
|
|
});
|
|
|
|
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = getAppSettings(authReq.user.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json(result.data);
|
|
});
|
|
|
|
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = updateAppSettings(authReq.user.id, req.body);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({
|
|
userId: authReq.user.id,
|
|
action: 'settings.app_update',
|
|
ip: getClientIp(req),
|
|
details: result.auditSummary,
|
|
debugDetails: result.auditDebugDetails,
|
|
});
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json(getTravelStats(authReq.user.id));
|
|
});
|
|
|
|
router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
|
|
const result = verifyMfaLogin(req.body);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
|
setAuthCookie(res, result.token!, req);
|
|
res.json({ token: result.token, user: result.user });
|
|
});
|
|
|
|
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = setupMfa(authReq.user.id, authReq.user.email);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
result.qrPromise!
|
|
.then((qr_svg: string) => {
|
|
res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg });
|
|
})
|
|
.catch((err: unknown) => {
|
|
console.error('[MFA] QR code generation error:', err);
|
|
res.status(500).json({ error: 'Could not generate QR code' });
|
|
});
|
|
});
|
|
|
|
router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = enableMfa(authReq.user.id, req.body.code);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
|
|
res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes });
|
|
});
|
|
|
|
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = disableMfa(authReq.user.id, authReq.user.email, req.body);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
|
|
res.json({ success: true, mfa_enabled: result.mfa_enabled });
|
|
});
|
|
|
|
// --- MCP Token Management ---
|
|
|
|
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
res.json({ tokens: listMcpTokens(authReq.user.id) });
|
|
});
|
|
|
|
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = createMcpToken(authReq.user.id, req.body.name);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.status(201).json({ token: result.token });
|
|
});
|
|
|
|
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = deleteMcpToken(authReq.user.id, req.params.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// Short-lived single-use token for WebSocket connections
|
|
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const result = createWsToken(authReq.user.id);
|
|
if (result.error) return res.status(result.status!).json({ error: result.error });
|
|
res.json({ token: result.token });
|
|
});
|
|
|
|
// Short-lived single-use token for direct resource URLs
|
|
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
|
|
const authReq = req as AuthRequest;
|
|
const token = createResourceToken(authReq.user.id, req.body.purpose);
|
|
if (!token) return res.status(503).json({ error: 'Service unavailable' });
|
|
res.json(token);
|
|
});
|
|
|
|
export default router;
|
|
|
|
// Exported for test resets only — do not use in production code
|
|
export { loginAttempts, mfaAttempts };
|