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
3 changes: 3 additions & 0 deletions backend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion backend/src/indexer/poller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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') {
Expand Down
26 changes: 24 additions & 2 deletions backend/src/middleware/issuer.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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;
1 change: 1 addition & 0 deletions backend/src/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
50 changes: 50 additions & 0 deletions backend/src/stellar/issuerCache.js
Original file line number Diff line number Diff line change
@@ -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<boolean>}
*/
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 };
3 changes: 2 additions & 1 deletion backend/tests/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
99 changes: 99 additions & 0 deletions backend/tests/issuer-verification.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading