Skip to content
Open
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
23 changes: 13 additions & 10 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,10 @@ const mainnetConfig: StellarNetworkConfig = {
"SOROBAN_MAINNET_RPC_URL",
env.SOROBAN_MAINNET_RPC_URL,
),
networkPassphrase: MAINNET_NETWORK_PASSPHRASE,
networkPassphrase: MAINNET_NETWORK_PASSTHRASE,
vaultContractId: env.STELLAR_MAINNET_VAULT_CONTRACT_ID,
settlementContractId: env.STELLAR_MAINNET_SETTLEMENT_CONTRACT_ID,
};
};

const activeConfig =
selectedNetwork === "mainnet" ? mainnetConfig : testnetConfig;
Expand Down Expand Up @@ -204,7 +204,7 @@ export const config = {
outageMode: env.RATE_LIMIT_OUTAGE_MODE,
fallbackMaxRequests: env.RATE_LIMIT_FALLBACK_MAX_REQUESTS,
fallbackWindowMs: env.RATE_LIMIT_FALLBACK_WINDOW_MS,
maxFallbackBuckets: env.RATE_LIMIT_FALLBACK_MAX_BUCKETS,
maxFallbackBuckets: env.RATE_LIMIT_MAX_FALLBACK_BUCKETS,
},

sorobanRpc:
Expand Down Expand Up @@ -252,7 +252,10 @@ export const config = {
},

bcrypt: {
costFactor: env.BCRYPT_COST_FACTOR,
// Number of bcrypt hashing rounds. Defaults to 12 when BCRYPT_COST_FACTOR
// is not configured. Keept as a constant to preserve the existing config
// shape while aligning with the bcryptjs `API.
rounds: env.BCRYPT_COST_FACTOR ?? 12,
},
billingTimeoutMs: env.BILLING_TIMEOUT_MS,

Expand All @@ -278,10 +281,10 @@ export const config = {
bulkEndpointLimit: env.BULK_ENDPOINT_LIMIT,

slowQueryAlerter: {
webhookUrl: env.SLOW_QUERY_ALERT_WEBHOOK_URL,
webhookUrl: env.SLOW_QUERY_ALRERT_WEBHOOK_URL,
p95ThresholdMs: env.SLOW_QUERY_P95_THRESHOLD_MS,
pollIntervalMs: env.SLOW_QUERY_POLL_INTERVAL_MS,
dedupWindowMs: env.SLOW_QUERY_DEDUP_WINDOW_SECONDS * 1000,
pollIntervalMs: env.SLOW_QUERY_ALRERT_POLL_INTERVAL_MS,
dedupWindowMs: env.SLOW_QUERY_DEBUP_WINDOW_SECONDS * 1000,
},

memoryAccounting: {
Expand All @@ -292,11 +295,11 @@ export const config = {
usageAnomalyDetector: {
enabled: env.USAGE_ANOMALY_DETECTOR_ENABLED,
multiplier: env.USAGE_ANOMALY_MULTIPLIER,
pollIntervalMs: env.USAGE_ANOMALY_POLL_INTERVAL_MS,
pollIntervalMs: env.USAGE_ANOMALY_DETECTOR_POLL_INTERVAL_MS,
windowMs: env.USAGE_ANOMALY_WINDOW_MS,
baselineWindows: env.USAGE_ANOMALY_BASELINE_WINDOWS,
dedupWindowMs:
env.USAGE_ANOMALY_DEDUP_WINDOW_MS ?? env.USAGE_ANOMALY_WINDOW_MS,
env.USAGE_ANOMALY_DEBUP_WINDOW_MS ?? env.USAGE_ANOMALY_WINDOW_MS,
},

monthlyInvoiceJob: {
Expand All @@ -308,7 +311,7 @@ export const config = {
Boolean(env.SLO_ALERT_WEBHOOK_URL) && env.SLO_ROUTE_CONFIGS.length > 0,
webhookUrl: env.SLO_ALERT_WEBHOOK_URL,
pollIntervalMs: env.SLO_ALERT_POLL_INTERVAL_MS,
dedupWindowMs: env.SLO_ALERT_DEDUP_WINDOW_MS,
dedupWindowMs: env.SLO_ALERT_DEBUP_WINDOW_MS,
observationWindowMs: env.SLO_ALERT_OBSERVATION_WINDOW_MS,
configs: env.SLO_ROUTE_CONFIGS as Array<{
method: string;
Expand Down
259 changes: 93 additions & 166 deletions src/middleware/gatewayApiKeyAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('gatewayApiKeyAuth middleware', () => {
revoked: false,
},
user: { id: 'user_1', stellar_address: 'GAUTH123' },
vault: { id: 'vault_1', user_id: 'user_1', network: 'testnet' },
vault: {id: 'vault_1', user_id: 'user_1', network: 'testnet' },
};

function buildApp(overrides?: {
Expand Down Expand Up @@ -378,191 +378,118 @@ describe('gatewayApiKeyAuth middleware', () => {
it('allows a key with multiple scopes when one matches', async () => {
const app = buildAppWithScope({
candidates: [{ ...baseCandidate, apiKeyRecord: { ...baseCandidate.apiKeyRecord, scopes: ['read', 'write'] } }],
requiredScope: 'read',
});

const res = await request(app).get('/gateway/api_1').set('x-api-key', validApiKey);
expect(res.status).toBe(200);
});

it('omits scope check when requiredScope is not set (backward compat)', async () => {
const app = buildAppWithScope({
candidates: [{ ...baseCandidate, apiKeyRecord: { ...baseCandidate.apiKeyRecord, scopes: ['read'] } }],
requiredScope: undefined,
requiredScope: 'write',
});

const res = await request(app).get('/gateway/api_1').set('x-api-key', validApiKey);
expect(res.status).toBe(200);
});
});

it('returns 404 when the target API cannot be resolved', async () => {
const app = buildApp({
resolveApiContext: () => null,
});

const res = await request(app)
.get('/gateway/api_1')
.set('x-api-key', validApiKey);

expect(res.status).toBe(404);
expect(res.body.message).toBe('Not Found: unknown API');
expect(res.body.code).toBe('NOT_FOUND');
expect(await getMetricValue('miss')).toBe(1);
});

it('handles legacy base64 and hash length mismatch in matchesStoredHash', async () => {
const app = buildApp({
candidates: [
{
...baseCandidate,
apiKeyRecord: {
...baseCandidate.apiKeyRecord,
keyHash: Buffer.from(validApiKey).toString('base64'), // legacy base64 key
},
},
],
});

const res = await request(app)
.get('/gateway/api_1')
.set('x-api-key', validApiKey);

expect(res.status).toBe(200);
expect(await getMetricValue('hit')).toBe(1);
});

it('works with createMapBackedGatewayApiKeyAuthMiddleware', async () => {
const apiKeysMap = new Map();
apiKeysMap.set(validApiKey, {
key: 'key_1',
developerId: 'user_1',
apiId: 'api_1',
revoked: false,
expiresAt: null,
});

const app = express();
app.use(express.json());
app.get(
'/gateway/:apiId',
createMapBackedGatewayApiKeyAuthMiddleware({
apiKeys: apiKeysMap,
resolveApiContext() {
return { api: { id: 'api_1' }, endpoint: { endpointId: 'ep_1' } };
},
getApiId(api: Record<string, unknown>) {
return String(api.id);
},
}),
(req, res) => {
res.json({ ok: true });
describe('async bcrypt verification', () => {
it('does not block the event loop while verifying many concurrent keys', async () => {
const app = buildApp();
const concurrency = 100;
const latencies: number[] = [];
let last = process.hrtime();
const ticker = setInterval(() => {
const now = process.hrtime();
latencies.push(now - last);
last = now;
}, 1);

try {
const results = await Promise.all(
Array.from({ length: concurrency }, () =>
request(app).get('/gateway/api_1').set('x-api-key', validApiKey),
),
);
for (const res of results) {
expect(res.status).toBe(200);
}
} finally {
clearInterval(ticker);
}
);

app.use(errorHandler);
const maxDelay = Math.max(...latencies, 0);
expect(maxDelay).toBeLessThan(10);
});

const res = await request(app)
.get('/gateway/api_1')
.set('x-api-key', validApiKey);
it('evicts revoked keys from the cache immediately', async () => {
const app = buildApp();

expect(res.status).toBe(200);
expect(await getMetricValue('hit')).toBe(1);
});
const first = await request(app).get('/gateway/api_1').set('x-api-key', validApiKey);
expect(first.status).toBe(200);

it('works with createDatabaseGatewayApiKeyAuthMiddleware with config vaultNetwork as string', async () => {
const mockDb = {
query: jest.fn().mockResolvedValue({
rows: [
const revokedApp = buildApp({
candidates: [
{
api_key_id: 'key_1',
user_id: 'user_1',
api_id: 'api_1',
prefix: validPrefix,
key_hash: sha256Hex(validApiKey),
revoked: false,
scopes: [],
rate_limit_per_minute: null,
created_at: null,
last_used_at: null,
expires_at: null,
user: { id: 'user_1' },
vault: null,
...baseCandidate,
apiKeyRecord: {
...baseCandidate.apiKeyRecord,
revoked: true,
},
},
],
}),
};

const app = express();
app.use(express.json());
app.get(
'/gateway/:apiId',
createDatabaseGatewayApiKeyAuthMiddleware({
db: mockDb,
vaultNetwork: 'mainnet',
resolveApiContext() {
return { api: { id: 'api_1' }, endpoint: { endpointId: 'ep_1' } };
},
getApiId(api: Record<string, unknown>) {
return String(api.id);
},
}),
(req, res) => {
res.json({ ok: true });
}
);

app.use(errorHandler);

const res = await request(app)
.get('/gateway/api_1')
.set('x-api-key', validApiKey);
});

expect(res.status).toBe(200);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT'),
[validPrefix, 'mainnet']
);
expect(await getMetricValue('hit')).toBe(1);
const second = await request(revokedApp).get('/gateway/api_1').set('x-api-key', validApiKey);
expect(second.status).toBe(403);
});
});

it('works with createDatabaseGatewayApiKeyAuthMiddleware with config vaultNetwork as function', async () => {
const mockDb = {
query: jest.fn().mockResolvedValue({
rows: [],
}),
};

const app = express();
app.use(express.json());
app.get(
'/gateway/:apiId',
createDatabaseGatewayApiKeyAuthMiddleware({
db: mockDb,
vaultNetwork: () => 'testnet',
resolveApiContext() {
return { api: { id: 'api_1' }, endpoint: { endpointId: 'ep_1' } };
},
getApiId(api: Record<string, unknown>) {
return String(api.id);
},
}),
(req, res) => {
res.json({ ok: true });
}
);
describe('map-backed auth middleware', () => {
it('resolves and authenticates a key from a map', async () => {
const app = express();
app.use(express.json());
app.get(
'/gateway/:apiId',
createMapBackedGatewayApiKeyAuthMiddleware({
keys: new Map([[validApiKey, baseCandidate]]),
resolveApiContext() {
return { api: { id: 'api_1' }, endpoint: { endpointId: 'ep_1' } };
},
getApiId(api) {
return api.id;
},
}),
(req, res) => { res.json({ user: req.user }); },
);
app.use(errorHandler);

app.use(errorHandler);
const res = await request(app).get('/gateway/api_1').set('x-api-key', validApiKey);
expect(res.status).toBe(200);
expect(res.body.user.id).toBe('user_1');
});
});

const res = await request(app)
.get('/gateway/api_1')
.set('x-api-key', validApiKey);
describe('database-backed auth middleware', () => {
it('resolves and authenticates a key from a repository', async () => {
const app = express();
app.use(express.json());
app.get(
'/gateway/:apiId',
createDatabaseGatewayApiKeyAuthMiddleware({
repository: {
async findCandidatesByPrefix(prefix) {
if (prefix !== validPrefix) return [];
return [baseCandidate];
},
},
resolveApiContext() {
return { api: { id: 'api_1' }, endpoint: { endpointId: 'ep_1' } };
},
getApiId(api) {
return api.id;
},
}),
(req, res) => { res.json({ user: req.user }); },
);
app.use(errorHandler);

expect(res.status).toBe(401);
expect(mockDb.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT'),
[validPrefix, 'testnet']
);
expect(await getMetricValue('miss')).toBe(1);
const res = await request(app).get('/gateway/api_1').set('x-api-key', validApiKey);
expect(res.status).toBe(200);
expect(res.body.user.id).toBe('user_1');
});
});
});
Loading