From 6a2de79d8023b3b8c040cd685b394c20dd5152e1 Mon Sep 17 00:00:00 2001 From: Senthil Raja R Date: Wed, 17 Jun 2026 22:46:08 +0530 Subject: [PATCH 1/4] Add global Express error handler and graceful shutdown to API Gateway --- services/api-gateway-node/index.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/services/api-gateway-node/index.js b/services/api-gateway-node/index.js index 4f11bb2..6ebcf92 100644 --- a/services/api-gateway-node/index.js +++ b/services/api-gateway-node/index.js @@ -816,6 +816,23 @@ app.get('/health', (req, res) => { }); app.get('/metrics', async (req, res) => { - res.set('Content-Type', promClient.register.contentType); - res.end(await promClient.register.metrics()); + res.set('Content-Type', promClient.register.contentType); + res.end(await promClient.register.metrics()); +}); + +function globalErrorHandler(err, req, res, _next) { + console.error('Unhandled error:', err.message, err.stack); + const status = err.status || err.statusCode || 500; + res.status(status).json({ error: 'Internal server error' }); +} +app.use(globalErrorHandler); + +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully...'); + server.close(() => process.exit(0)); +}); + +process.on('SIGINT', () => { + console.log('SIGINT received, shutting down gracefully...'); + server.close(() => process.exit(0)); }); From 7938bf7f3f1fafd2728a83eef2d3fec7c02fba40 Mon Sep 17 00:00:00 2001 From: Senthil Raja R Date: Wed, 17 Jun 2026 22:47:42 +0530 Subject: [PATCH 2/4] Fix MFA backup codes shown-once enforcement Add backup_codes_shown flag to user_mfa table. Backup codes are only returned on the initial setup request and marked as shown afterwards. Re-running setup will regenerate codes but not display them. Add a /mfa/rotate-backup-codes endpoint that requires current TOTP token to view new backup codes. --- services/auth-service/index.js | 57 +++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/services/auth-service/index.js b/services/auth-service/index.js index 13e7f92..4789f4a 100644 --- a/services/auth-service/index.js +++ b/services/auth-service/index.js @@ -494,7 +494,8 @@ async function initDB() { user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, mfa_secret VARCHAR(255) NOT NULL, backup_codes JSONB DEFAULT '[]'::jsonb, - mfa_enabled BOOLEAN DEFAULT false + mfa_enabled BOOLEAN DEFAULT false, + backup_codes_shown BOOLEAN DEFAULT false ); `); @@ -883,28 +884,70 @@ app.post('/mfa/setup', requireRole(), createUserRateLimiter('mfa_setup', 5), asy const secret = authenticator.generateSecret(); const backupCodes = Array.from({ length: 10 }, () => crypto.randomBytes(5).toString('hex').slice(0, 8).toUpperCase()); + const existing = await pool.query('SELECT backup_codes_shown FROM user_mfa WHERE user_id = $1', [userId]); + const alreadyShown = existing.rows[0]?.backup_codes_shown === true; + await pool.query(` INSERT INTO user_mfa (user_id, mfa_secret, backup_codes, mfa_enabled) VALUES ($1, $2, $3::jsonb, false) ON CONFLICT (user_id) DO UPDATE SET mfa_secret = EXCLUDED.mfa_secret, backup_codes = EXCLUDED.backup_codes, - mfa_enabled = false + mfa_enabled = false, + backup_codes_shown = false `, [userId, secret, JSON.stringify(backupCodes)]); const otpauth = authenticator.keyuri(req.user.email, 'Atlas Workforce', secret); - res.json({ - secret, - qr_code_uri: otpauth, - backup_codes: backupCodes - }); + const response = { secret, qr_code_uri: otpauth }; + if (alreadyShown) { + response.message = 'Backup codes were regenerated. Use /mfa/rotate-backup-codes to view new codes with re-authentication.'; + } else { + await pool.query('UPDATE user_mfa SET backup_codes_shown = true WHERE user_id = $1', [userId]); + response.backup_codes = backupCodes; + response.message = 'Save these backup codes. They will not be shown again.'; + } + + res.json(response); } catch (error) { console.error(error); res.status(500).json({ message: 'MFA setup failed' }); } }); +app.post('/mfa/rotate-backup-codes', requireRole(), createUserRateLimiter('mfa_verify', 5), async (req, res) => { + try { + const { token } = req.body; + if (!token) { + return res.status(400).json({ message: 'Current TOTP token is required to rotate backup codes' }); + } + + const userId = req.user.id; + const result = await pool.query('SELECT * FROM user_mfa WHERE user_id = $1', [userId]); + if (!result.rows[0]) { + return res.status(400).json({ message: 'MFA not set up' }); + } + + const isValid = authenticator.check(token, result.rows[0].mfa_secret); + if (!isValid) { + return res.status(400).json({ message: 'Invalid token' }); + } + + const newCodes = Array.from({ length: 10 }, () => crypto.randomBytes(5).toString('hex').slice(0, 8).toUpperCase()); + + await pool.query(` + UPDATE user_mfa SET backup_codes = $1::jsonb, backup_codes_shown = true WHERE user_id = $2 + `, [JSON.stringify(newCodes), userId]); + + await sendAuditEvent('auth.mfa_rotate_codes', userId, req.user.email); + + res.json({ message: 'Backup codes rotated successfully. Save these codes as they will not be shown again.', backup_codes: newCodes }); + } catch (error) { + console.error(error); + res.status(500).json({ message: 'Failed to rotate backup codes' }); + } +}); + app.post('/mfa/verify', requireRole(), createUserRateLimiter('mfa_verify', 10), async (req, res) => { try { const { token } = req.body; From 36db375f142de3b17b9c86d3087f9c67a6233f19 Mon Sep 17 00:00:00 2001 From: Senthil Raja R Date: Wed, 17 Jun 2026 22:48:51 +0530 Subject: [PATCH 3/4] Remove magic link token from passwordless API response Prevent token leak by removing the raw passwordless token from the API response body. The token is still stored hashed in the database and can be verified via /auth/passwordless/verify. The response now only returns a generic success message. --- services/auth-service/index.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/services/auth-service/index.js b/services/auth-service/index.js index 4789f4a..904bffc 100644 --- a/services/auth-service/index.js +++ b/services/auth-service/index.js @@ -1652,9 +1652,7 @@ app.post('/auth/passwordless/request', createUserRateLimiter('passwordless_reque await sendAuditEvent('auth.passwordless_requested', user.id, email); res.json({ - message: 'Magic link sent', - token, - expires_in: 900 + message: 'Check your email for the login link' }); } catch (err) { console.error(err); From 27bdf16a8b7094c14bc07ff7c7b0ee92f4e4c5f9 Mon Sep 17 00:00:00 2001 From: Senthil Raja R Date: Wed, 17 Jun 2026 22:50:39 +0530 Subject: [PATCH 4/4] Replace fragile SAML regex XML parsing with DOM-based extraction Replace 4 fragile regex patterns for extracting NameID and SAML attribute values with robust DOM-based extraction using the already-parsed XML document. This fixes namespace sensitivity, encoding variations, and CDATA handling issues. Also moves the DOMParser outside the SAML_IDP_CERT conditional so it is always available for attribute extraction. --- services/auth-service/index.js | 37 +++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/services/auth-service/index.js b/services/auth-service/index.js index 904bffc..5187a23 100644 --- a/services/auth-service/index.js +++ b/services/auth-service/index.js @@ -1474,8 +1474,9 @@ app.post('/saml/acs', createUserRateLimiter('saml_acs', 20), async (req, res) => console.warn('SAML_IDP_CERT not configured — signature verification disabled'); } + const doc = new DOMParser().parseFromString(decodedXml, 'text/xml'); + if (SAML_IDP_CERT) { - const doc = new DOMParser().parseFromString(decodedXml, 'text/xml'); const signatureNodes = doc.getElementsByTagNameNS ? doc.getElementsByTagNameNS('http://www.w3.org/2000/09/xmldsig#', 'Signature') : doc.getElementsByTagName('Signature'); @@ -1534,15 +1535,33 @@ app.post('/saml/acs', createUserRateLimiter('saml_acs', 20), async (req, res) => } } - const nameIdMatch = decodedXml.match(/]*>([^<]+)<\/saml2:NameID>/); - const emailMatch = decodedXml.match(/]*?Name="[^"]*email[^"]*"[^>]*>[\s\S]*?]*>([^<]+)<\/saml2:AttributeValue>/i); - const firstNameMatch = decodedXml.match(/]*?Name="[^"]*firstName[^"]*"[^>]*>[\s\S]*?]*>([^<]+)<\/saml2:AttributeValue>/i); - const lastNameMatch = decodedXml.match(/]*?Name="[^"]*lastName[^"]*"[^>]*>[\s\S]*?]*>([^<]+)<\/saml2:AttributeValue>/i); + const ns = 'urn:oasis:names:tc:SAML:2.0:assertion'; + + let email = ''; + const nameIdNodes = doc.getElementsByTagNameNS ? doc.getElementsByTagNameNS(ns, 'NameID') : doc.getElementsByTagName('NameID'); + if (nameIdNodes.length > 0) { + email = nameIdNodes[0].textContent || ''; + } + + let firstName = ''; + let lastName = ''; - const emailSimpleMatch = decodedXml.match(/]*>([^<]+)<\/NameID>/); - const email = emailMatch?.[1] || nameIdMatch?.[1] || emailSimpleMatch?.[1]; - const firstName = firstNameMatch?.[1] || ''; - const lastName = lastNameMatch?.[1] || ''; + const attrNodes = doc.getElementsByTagNameNS ? doc.getElementsByTagNameNS(ns, 'Attribute') : doc.getElementsByTagName('Attribute'); + for (let i = 0; i < attrNodes.length; i++) { + const name = attrNodes[i].getAttribute('Name') || ''; + const valNodes = attrNodes[i].getElementsByTagNameNS + ? attrNodes[i].getElementsByTagNameNS(ns, 'AttributeValue') + : attrNodes[i].getElementsByTagName('AttributeValue'); + const value = valNodes.length > 0 ? (valNodes[0].textContent || '') : ''; + const lower = name.toLowerCase(); + if (lower.includes('email') && !email) { + email = value; + } else if (lower.includes('firstname') || lower.includes('givenname')) { + firstName = value; + } else if (lower.includes('lastname') || lower.includes('surname')) { + lastName = value; + } + } const displayName = `${firstName} ${lastName}`.trim() || email; if (!email) {