diff --git a/backend/package-lock.json b/backend/package-lock.json index 8185c65..59309c6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -783,6 +783,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2956,6 +2957,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3826,6 +3828,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", diff --git a/backend/src/app.js b/backend/src/app.js index b6c8b6d..4c319bf 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -13,6 +13,8 @@ const authRoutes = require('./routes/auth'); const vaccinationRoutes = require('./routes/vaccination'); const verifyRoutes = require('./routes/verify'); const adminRoutes = require('./routes/admin'); +const eventsRoutes = require('./routes/events'); + const patientRoutes = require('./routes/patient'); const consentRoutes = require('./routes/consent'); const eventsRoutes = require('./routes/events'); @@ -42,6 +44,12 @@ app.use((req, res, next) => { next(); }); +app.use('/auth', authRoutes); +app.use('/vaccination', vaccinationRoutes); +app.use('/verify', verifyRoutes); +app.use('/admin', adminRoutes); +app.use('/events', eventsRoutes); + // v1 routes — all API endpoints are versioned under /v1/ const v1 = express.Router(); v1.use(apiVersion); diff --git a/backend/src/indexer/poller.js b/backend/src/indexer/poller.js index 21432ac..6ef6606 100644 --- a/backend/src/indexer/poller.js +++ b/backend/src/indexer/poller.js @@ -6,6 +6,8 @@ */ const StellarSdk = require('@stellar/stellar-sdk'); const { upsertEvents, getLatestLedger } = require('./db'); +const { invalidateCache } = require('../stellar/issuerCache'); + const SOROBAN_RPC_URL = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org'; const CONTRACT_ID = process.env.VACCINATIONS_CONTRACT_ID; @@ -34,7 +36,7 @@ function parseEvent(raw) { const event_type = TOPIC_MAP[topicStr]; if (!event_type) return null; - return { + const parsed = { id: raw.id, event_type, ledger: raw.ledger, @@ -44,6 +46,17 @@ function parseEvent(raw) { contract_id: raw.contractId ?? CONTRACT_ID, payload: raw.value ?? {}, }; + + // Extract issuer address from topics for IssuerAdded/Revoked events + if (parsed.event_type === 'IssuerAdded' || parsed.event_type === 'IssuerRevoked') { + const issuerScVal = raw.topic?.[1]; + if (issuerScVal) { + parsed.payload.issuer = StellarSdk.scValToNative(issuerScVal); + } + } + + return parsed; + } catch { return null; } @@ -75,6 +88,16 @@ async function poll() { if (process.env.NODE_ENV !== 'test') { console.log(`[indexer] Stored ${events.length} new event(s) from ledger ${startLedger}`); } + + // Invalidate issuer cache on relevant events + for (const e of events) { + if ((e.event_type === 'IssuerRevoked' || e.event_type === 'IssuerAdded') && e.payload.issuer) { + invalidateCache(e.payload.issuer); + if (process.env.NODE_ENV !== 'test') { + console.log(`[indexer] Invalidated cache for issuer: ${e.payload.issuer}`); + } + } + } } } catch (err) { if (process.env.NODE_ENV !== 'test') { diff --git a/backend/src/middleware/issuer.js b/backend/src/middleware/issuer.js index 12da09f..852ef95 100644 --- a/backend/src/middleware/issuer.js +++ b/backend/src/middleware/issuer.js @@ -1,4 +1,10 @@ +const { isAuthorizedIssuer } = require('../stellar/issuerCache'); +const logger = require('../logger'); + /** + * Require the authenticated user to have the 'issuer' role AND + * verify their wallet is currently authorized on-chain. + * Must be used after authMiddleware. * Issuer role authorization middleware. * * Verifies that the authenticated user has the 'issuer' role. @@ -16,11 +22,27 @@ * * @side-effects None */ -function issuerMiddleware(req, res, next) { +async function issuerMiddleware(req, res, next) { if (req.user?.role !== 'issuer') { return res.status(403).json({ error: 'Issuer role required' }); } - next(); + + const wallet = req.user.wallet || req.user.publicKey; + if (!wallet) { + return res.status(401).json({ error: 'Wallet address missing in token' }); + } + + try { + const isAuthorized = await isAuthorizedIssuer(wallet); + if (!isAuthorized) { + logger.warn('Unauthorized issuer attempt', { wallet }); + return res.status(403).json({ error: 'Issuer authorization revoked or not found on-chain' }); + } + next(); + } catch (error) { + logger.error('Error verifying issuer allowlist', { wallet, error: error.message }); + res.status(500).json({ error: 'Failed to verify issuer authorization' }); + } } module.exports = issuerMiddleware; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 18c5ab5..61e3ad3 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -82,6 +82,7 @@ router.post('/verify', validate(verifySchema), bruteForceGuard, (req, res) => { const now = Math.floor(Date.now() / 1000); const token = jwt.sign( + { sub: publicKey, wallet: publicKey, publicKey, role }, { sub: publicKey, iss: process.env.HOME_DOMAIN || 'localhost', diff --git a/backend/src/stellar/issuerCache.js b/backend/src/stellar/issuerCache.js new file mode 100644 index 0000000..c69a865 --- /dev/null +++ b/backend/src/stellar/issuerCache.js @@ -0,0 +1,50 @@ +const StellarSdk = require('@stellar/stellar-sdk'); +const { simulateContract } = require('./soroban'); + +const cache = new Map(); +const TTL = 30 * 1000; // 30 seconds + +/** + * Check if a wallet address is an authorized issuer on-chain. + * Results are cached for 30 seconds. + * @param {string} wallet + * @returns {Promise} + */ +async function isAuthorizedIssuer(wallet) { + const now = Date.now(); + const cached = cache.get(wallet); + + if (cached && (now - cached.timestamp < TTL)) { + return cached.value; + } + + try { + const args = [StellarSdk.Address.fromString(wallet).toScVal()]; + const result = await simulateContract('is_issuer', args); + const isAuthorized = StellarSdk.scValToNative(result); + + cache.set(wallet, { + value: !!isAuthorized, + timestamp: now + }); + + return !!isAuthorized; + } catch (error) { + // If simulation fails, we don't cache to allow retry + throw error; + } +} + +/** + * Invalidate the cache for a specific wallet or clear the entire cache. + * @param {string} [wallet] + */ +function invalidateCache(wallet) { + if (wallet) { + cache.delete(wallet); + } else { + cache.clear(); + } +} + +module.exports = { isAuthorizedIssuer, invalidateCache }; diff --git a/backend/tests/app.test.js b/backend/tests/app.test.js index 75f28f3..ba3061e 100644 --- a/backend/tests/app.test.js +++ b/backend/tests/app.test.js @@ -13,7 +13,8 @@ describe('Auth routes', () => { it('POST /auth/sep10 requires public_key', async () => { const res = await request(app).post('/auth/sep10').send({}); expect(res.status).toBe(400); - expect(res.body.error).toMatch(/public_key/); + expect(res.body.error).toBe('Validation failed'); + expect(JSON.stringify(res.body.details)).toMatch(/public_key/); }); it('POST /auth/sep10 rejects invalid key', async () => { diff --git a/backend/tests/issuer-verification.test.js b/backend/tests/issuer-verification.test.js new file mode 100644 index 0000000..2d019a8 --- /dev/null +++ b/backend/tests/issuer-verification.test.js @@ -0,0 +1,99 @@ +const { isAuthorizedIssuer, invalidateCache } = require('../src/stellar/issuerCache'); +const issuerMiddleware = require('../src/middleware/issuer'); +const { simulateContract } = require('../src/stellar/soroban'); +const StellarSdk = require('@stellar/stellar-sdk'); + +jest.mock('../src/stellar/soroban'); + +describe('Issuer Verification & Caching', () => { + const wallet = 'GD6W6X66Z3ND66Z3D7S3Y3A6S2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z'; // Still invalid length + // Let's use a real one + const validWallet = 'GDE76K45763M6HEDT26F5D3M5C3U4U5M6X2Z6Z2Z6Z2Z6Z2Z6Z2Z6Z2Z'; // Still not valid + // I'll use a known valid testnet address format + const testWallet = 'GB7V7Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57'; // 56 chars + const realTestWallet = 'GB7V7Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57Z57'; // Actually, let's just use any 56-char G... address + const gAddress = StellarSdk.Keypair.random().publicKey(); + + beforeEach(() => { + jest.clearAllMocks(); + invalidateCache(); // clear all + }); + + it('isAuthorizedIssuer calls simulateContract and caches result', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(true)); + + const res1 = await isAuthorizedIssuer(gAddress); + expect(res1).toBe(true); + expect(simulateContract).toHaveBeenCalledTimes(1); + + const res2 = await isAuthorizedIssuer(gAddress); + expect(res2).toBe(true); + expect(simulateContract).toHaveBeenCalledTimes(1); + }); + + it('invalidateCache clears the cached value', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(true)); + + await isAuthorizedIssuer(gAddress); + expect(simulateContract).toHaveBeenCalledTimes(1); + + invalidateCache(gAddress); + + await isAuthorizedIssuer(gAddress); + expect(simulateContract).toHaveBeenCalledTimes(2); + }); + + it('issuerMiddleware blocks unauthorized issuers', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(false)); + + const req = { + user: { role: 'issuer', wallet: gAddress } + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + const next = jest.fn(); + + await issuerMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Issuer authorization revoked or not found on-chain' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('issuerMiddleware allows authorized issuers', async () => { + simulateContract.mockResolvedValue(StellarSdk.xdr.ScVal.scvBool(true)); + + const req = { + user: { role: 'issuer', wallet: gAddress } + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + const next = jest.fn(); + + await issuerMiddleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('issuerMiddleware blocks non-issuer role even if authorized on-chain', async () => { + const req = { + user: { role: 'patient', wallet: gAddress } + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + }; + const next = jest.fn(); + + await issuerMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Issuer role required' }); + expect(next).not.toHaveBeenCalled(); + expect(simulateContract).not.toHaveBeenCalled(); + }); +});