Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion services/ai-copilot-service/test_main.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import base64
import hashlib
import hmac
import json
import time

import pytest
from httpx import AsyncClient, ASGITransport
from main import app

INTERNAL_JWT_SECRET = "test-secret"


def _make_internal_token(secret: str) -> str:
hdr = base64.urlsafe_b64encode(
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
).rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(
json.dumps({"sub": "test", "exp": int(time.time()) + 3600}).encode()
).rstrip(b"=").decode()
sig = base64.urlsafe_b64encode(
hmac.new(secret.encode(), f"{hdr}.{payload}".encode(), hashlib.sha256).digest()
).rstrip(b"=").decode()
return f"{hdr}.{payload}.{sig}"


@pytest.fixture
def client():
transport = ASGITransport(app=app)
return AsyncClient(transport=transport, base_url="http://test")
token = _make_internal_token(INTERNAL_JWT_SECRET)
return AsyncClient(transport=transport, base_url="http://test", headers={"x-internal-auth": token})


@pytest.mark.asyncio
Expand Down
21 changes: 19 additions & 2 deletions services/api-gateway-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
26 changes: 25 additions & 1 deletion services/ats-service/test_main.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,37 @@
"""Tests for the ATS Service API using FastAPI TestClient."""

import base64
import hashlib
import hmac
import json
import os
import time

import pytest
from fastapi.testclient import TestClient
from main import app

os.environ.setdefault("INTERNAL_JWT_SECRET", "test-secret")
INTERNAL_JWT_SECRET = "test-secret"


def _make_internal_token(secret: str) -> str:
hdr = base64.urlsafe_b64encode(
json.dumps({"alg": "HS256", "typ": "JWT"}).encode()
).rstrip(b"=").decode()
payload = base64.urlsafe_b64encode(
json.dumps({"sub": "test", "exp": int(time.time()) + 3600}).encode()
).rstrip(b"=").decode()
sig = base64.urlsafe_b64encode(
hmac.new(secret.encode(), f"{hdr}.{payload}".encode(), hashlib.sha256).digest()
).rstrip(b"=").decode()
return f"{hdr}.{payload}.{sig}"


@pytest.fixture
def client():
return TestClient(app)
token = _make_internal_token(INTERNAL_JWT_SECRET)
return TestClient(app, headers={"x-internal-auth": token})


def test_health_check(client):
Expand Down
108 changes: 84 additions & 24 deletions services/auth-service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ if (!SCIM_API_KEY) {

const jwtSecret = JWT_SECRET;

if (!process.env.POSTGRES_URL) {
console.error('FATAL: POSTGRES_URL environment variable is required');
process.exit(1);
}

const sslConfig = process.env.POSTGRES_SSL === 'true' || NODE_ENV === 'production'
? { rejectUnauthorized: true }
: false;
Expand All @@ -130,11 +135,6 @@ const pool = new Pool({
ssl: sslConfig,
});

if (!process.env.POSTGRES_URL) {
console.error('FATAL: POSTGRES_URL environment variable is required');
process.exit(1);
}

const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
const redisClient = redis.createClient({ url: REDIS_URL });
redisClient.on('error', (err) => console.log('Auth Redis Client Error', err));
Expand Down Expand Up @@ -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
);
`);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1431,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');
Expand Down Expand Up @@ -1491,15 +1535,33 @@ app.post('/saml/acs', createUserRateLimiter('saml_acs', 20), async (req, res) =>
}
}

const nameIdMatch = decodedXml.match(/<saml2:NameID[^>]*>([^<]+)<\/saml2:NameID>/);
const emailMatch = decodedXml.match(/<saml2:Attribute[^>]*?Name="[^"]*email[^"]*"[^>]*>[\s\S]*?<saml2:AttributeValue[^>]*>([^<]+)<\/saml2:AttributeValue>/i);
const firstNameMatch = decodedXml.match(/<saml2:Attribute[^>]*?Name="[^"]*firstName[^"]*"[^>]*>[\s\S]*?<saml2:AttributeValue[^>]*>([^<]+)<\/saml2:AttributeValue>/i);
const lastNameMatch = decodedXml.match(/<saml2:Attribute[^>]*?Name="[^"]*lastName[^"]*"[^>]*>[\s\S]*?<saml2:AttributeValue[^>]*>([^<]+)<\/saml2:AttributeValue>/i);
const ns = 'urn:oasis:names:tc:SAML:2.0:assertion';

const emailSimpleMatch = decodedXml.match(/<NameID[^>]*>([^<]+)<\/NameID>/);
const email = emailMatch?.[1] || nameIdMatch?.[1] || emailSimpleMatch?.[1];
const firstName = firstNameMatch?.[1] || '';
const lastName = lastNameMatch?.[1] || '';
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 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) {
Expand Down Expand Up @@ -1609,9 +1671,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);
Expand Down
8 changes: 4 additions & 4 deletions services/employee-python-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ def log_event(level: str, event: str, **kwargs):
# ------------------------------------------------
# Configuration
# ------------------------------------------------
MONGO_USER = os.environ["MONGO_USER"]
MONGO_PASSWORD = os.environ["MONGO_PASSWORD"]
MONGO_USER = os.environ.get("MONGO_USER", "")
MONGO_PASSWORD = os.environ.get("MONGO_PASSWORD", "")
MONGO_HOST = os.environ.get("MONGO_HOST", "localhost")
MONGO_DB = os.environ.get("MONGO_DB", "atlas_db")
MONGO_URL = os.environ.get(
Expand All @@ -62,11 +62,11 @@ def log_event(level: str, event: str, **kwargs):
)
DB_NAME = "atlas_db"

INTERNAL_KEY = os.environ["INTERNAL_KEY"]
INTERNAL_KEY = os.environ.get("INTERNAL_KEY", "")

RABBITMQ_HOST = os.environ.get("RABBITMQ_HOST", "localhost")
RABBITMQ_PORT = int(os.environ.get("RABBITMQ_PORT", "5672"))
RABBITMQ_USER = os.environ["RABBITMQ_USER"]
RABBITMQ_USER = os.environ.get("RABBITMQ_USER", "guest")
RABBITMQ_PASSWORD = os.environ["RABBITMQ_PASSWORD"]

client = AsyncIOMotorClient(MONGO_URL)
Expand Down
Loading