Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
c4f975b
Add challenge_nonces table and nonce store/consume/sweep helpers
Ojukwu-Chinedu Aug 25, 2026
8f42d40
Store issued nonces and use configurable network passphrase in challenge
Ojukwu-Chinedu Aug 25, 2026
3fcaa43
Verify challenge structure and consume nonce on successful auth
Ojukwu-Chinedu Aug 25, 2026
6225b66
Add tests for auth challenge endpoint
Ojukwu-Chinedu Aug 25, 2026
0f64c16
Add tests for auth verify endpoint
Ojukwu-Chinedu Aug 25, 2026
d6900fb
Document STELLAR_NETWORK_PASSPHRASE environment variable
Ojukwu-Chinedu Aug 25, 2026
0c6b6d3
Add limit and default time window to /api/routes endpoint
Ojukwu-Chinedu Aug 25, 2026
197d4d5
Add tests for /api/routes endpoint
Ojukwu-Chinedu Aug 25, 2026
31ae74a
Merge branch 'main' into fix/open-source-issues
Ojukwu-Chinedu Aug 25, 2026
64ca07e
fix(db): apply Prettier formatting to challenge_nonces schema
Ojukwu-Chinedu Aug 25, 2026
d4ddbad
fix(auth): apply Prettier formatting to challenge route
Ojukwu-Chinedu Aug 25, 2026
dd22a6a
fix(auth): apply Prettier formatting to verify route
Ojukwu-Chinedu Aug 25, 2026
64401b2
fix(routes): apply Prettier formatting and fix types for route aggreg…
Ojukwu-Chinedu Aug 25, 2026
cab5b42
fix(docs): apply Prettier formatting to DEPLOYMENT.md
Ojukwu-Chinedu Aug 25, 2026
51fe43a
fix(test): use vi.hoisted() to fix TypeScript error in challenge test
Ojukwu-Chinedu Aug 25, 2026
f9f9ac1
fix(test): use vi.hoisted() to fix TypeScript error in verify test
Ojukwu-Chinedu Aug 25, 2026
5c89ac1
fix(test): apply Prettier formatting to routes endpoint test
Ojukwu-Chinedu Aug 25, 2026
5fa3f1f
chore: add .prettierrc with singleQuote to match codebase convention
Ojukwu-Chinedu Aug 25, 2026
a14cd8f
fix(routes): reformat route aggregation with single quotes for CI
Ojukwu-Chinedu Aug 25, 2026
63788cd
fix(test): reformat challenge test with single quotes for CI
Ojukwu-Chinedu Aug 25, 2026
48c1fdc
fix(test): reformat verify test with single quotes for CI
Ojukwu-Chinedu Aug 25, 2026
4f7cd8b
fix(test): reformat routes test with single quotes for CI
Ojukwu-Chinedu Aug 25, 2026
8d5beeb
style: apply Prettier formatting to 5 files
Ojukwu-Chinedu Aug 26, 2026
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
15 changes: 8 additions & 7 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,14 @@ The pooler host looks like `aws-1-<region>.pooler.supabase.com`.

## Environment variables

| Variable | Where | What it does |
| ----------------------------- | ------------------------------ | --------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Vercel (`web`) | Supabase **session pooler** connection string. |
| `CRON_SECRET` | Vercel (`web`) + GitHub secret | Shared by `/api/sync` and `sync.yml`. Anonymous callers get `{"error":"Unauthorized"}`. |
| `SYNC_URL` | GitHub secret | The `/api/sync` endpoint the workflow posts to. |
| `HOOK_API_KEY` | Vercel (`web`) | Gates `/api/hook/settle`. **Absent means the endpoint fails closed**, not open. |
| `NEXT_PUBLIC_REFUND_VAULT_ID` | Vercel (`web`), optional | Overrides the built-in RefundVault contract id. |
| Variable | Where | What it does |
| ----------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Vercel (`web`) | Supabase **session pooler** connection string. |
| `CRON_SECRET` | Vercel (`web`) + GitHub secret | Shared by `/api/sync` and `sync.yml`. Anonymous callers get `{"error":"Unauthorized"}`. |
| `SYNC_URL` | GitHub secret | The `/api/sync` endpoint the workflow posts to. |
| `HOOK_API_KEY` | Vercel (`web`) | Gates `/api/hook/settle`. **Absent means the endpoint fails closed**, not open. |
| `NEXT_PUBLIC_REFUND_VAULT_ID` | Vercel (`web`), optional | Overrides the built-in RefundVault contract id. |
| `STELLAR_NETWORK_PASSPHRASE` | Vercel (`web`), optional | Stellar network passphrase for auth challenges and RPC calls. Defaults to `Test SDF Network ; September 2015`. Set to `Public Global Stellar Network ; September 2015` for pubnet. |

Set them per environment (`production`, `preview`, `development`) — Vercel does
not share values across them.
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/app/api/auth/challenge/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { expect, test, vi, describe, beforeEach } from 'vitest';
import { Keypair } from '@stellar/stellar-sdk';
import { GET } from './route';

const MERCHANT_KEYPAIR = Keypair.random();

const { mockStoreNonce, mockSweepExpiredNonces, mockEnsureSchema, mockWithClient } = vi.hoisted(
() => ({
mockStoreNonce: vi.fn().mockResolvedValue(undefined),
mockSweepExpiredNonces: vi.fn().mockResolvedValue(undefined),
mockEnsureSchema: vi.fn().mockResolvedValue(undefined),
mockWithClient: vi.fn(async (fn: (client: unknown) => Promise<unknown>) => {
return fn({});
}),
}),
);

vi.mock('@/lib/db', () => ({
withClient: mockWithClient,
ensureSchema: mockEnsureSchema,
storeNonce: mockStoreNonce,
sweepExpiredNonces: mockSweepExpiredNonces,
}));

vi.mock('@/lib/db', () => ({
withClient: mockWithClient,
ensureSchema: mockEnsureSchema,
storeNonce: mockStoreNonce,
sweepExpiredNonces: mockSweepExpiredNonces,
}));

describe('/api/auth/challenge GET', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.MERCHANT_ADDRESS = MERCHANT_KEYPAIR.publicKey();
});

test('returns xdr and configured network passphrase', async () => {
process.env.STELLAR_NETWORK_PASSPHRASE = 'Public Global Stellar Network ; September 2015';
const res = await GET();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.xdr).toBeDefined();
expect(typeof data.xdr).toBe('string');
expect(data.networkPassphrase).toBe('Public Global Stellar Network ; September 2015');
});

test('defaults to Networks.TESTNET when STELLAR_NETWORK_PASSPHRASE is unset', async () => {
delete process.env.STELLAR_NETWORK_PASSPHRASE;
const res = await GET();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.networkPassphrase).toBe('Test SDF Network ; September 2015');
});

test('persists the nonce to the database', async () => {
delete process.env.STELLAR_NETWORK_PASSPHRASE;
const res = await GET();
expect(res.status).toBe(200);
expect(mockStoreNonce).toHaveBeenCalledTimes(1);
const nonceArg = mockStoreNonce.mock.calls[0][1] as string;
expect(nonceArg).toMatch(/^[0-9a-f]{64}$/);
expect(mockSweepExpiredNonces).toHaveBeenCalledTimes(1);
});

test('returns 500 when MERCHANT_ADDRESS is not configured', async () => {
delete process.env.MERCHANT_ADDRESS;
const res = await GET();
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe('MERCHANT_ADDRESS not configured');
});
});
25 changes: 22 additions & 3 deletions apps/web/src/app/api/auth/challenge/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { NextResponse } from 'next/server';
import { TransactionBuilder, Account, Operation, Networks } from '@stellar/stellar-sdk';
import { randomBytes } from 'crypto';
import { withClient, ensureSchema, storeNonce, sweepExpiredNonces } from '@/lib/db';

export const dynamic = 'force-dynamic';

function networkPassphrase(): string {
return process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET;
}

export async function GET() {
const merchantAddress = process.env.MERCHANT_ADDRESS;
if (!merchantAddress) {
Expand All @@ -16,16 +21,30 @@ export async function GET() {
// Create a SEP-10 style challenge transaction
// The source account is the merchant, sequence is 0
const now = Math.floor(Date.now() / 1000);
const passphrase = networkPassphrase();
const tx = new TransactionBuilder(new Account(merchantAddress, '0'), {
fee: '100',
networkPassphrase: Networks.TESTNET,
networkPassphrase: passphrase,
timebounds: { minTime: now - 60, maxTime: now + 300 },
})
.addOperation(Operation.manageData({ name: 'Accensa Auth', value: nonce.substring(0, 64) }))
.addOperation(
Operation.manageData({
name: 'Accensa Auth',
value: nonce.substring(0, 64),
}),
)
.build();

// Persist the nonce so /api/auth/verify can confirm it was issued here
// and has not already been used. Sweep expired nonces opportunistically.
await withClient(async (client) => {
await ensureSchema(client);
await storeNonce(client, nonce);
await sweepExpiredNonces(client);
});

return NextResponse.json({
xdr: tx.toXDR(),
networkPassphrase: Networks.TESTNET,
networkPassphrase: passphrase,
});
}
195 changes: 195 additions & 0 deletions apps/web/src/app/api/auth/verify/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { expect, test, vi, describe, beforeEach } from 'vitest';
import { Keypair, TransactionBuilder, Account, Operation, Networks } from '@stellar/stellar-sdk';
import { POST } from './route';

const MERCHANT_KEYPAIR = Keypair.random();
const MERCHANT_ADDRESS = MERCHANT_KEYPAIR.publicKey();

const { mockConsumeNonce, mockCreateSession, mockWithClient, mockEnsureSchema } = vi.hoisted(
() => ({
mockConsumeNonce: vi.fn(),
mockCreateSession: vi.fn().mockResolvedValue(undefined),
mockWithClient: vi.fn(async (fn: (client: unknown) => Promise<unknown>) => {
return fn({});
}),
mockEnsureSchema: vi.fn().mockResolvedValue(undefined),
}),
);

vi.mock('@/lib/auth', () => ({
createSession: mockCreateSession,
}));

vi.mock('@/lib/db', () => ({
withClient: mockWithClient,
ensureSchema: mockEnsureSchema,
consumeNonce: mockConsumeNonce,
}));

function buildChallenge(nonce: string, passphrase = Networks.TESTNET) {
const now = Math.floor(Date.now() / 1000);
return new TransactionBuilder(new Account(MERCHANT_ADDRESS, '0'), {
fee: '100',
networkPassphrase: passphrase,
timebounds: { minTime: now - 60, maxTime: now + 300 },
})
.addOperation(Operation.manageData({ name: 'Accensa Auth', value: nonce }))
.build();
}

function buildNonChallengeTransaction(passphrase = Networks.TESTNET) {
const now = Math.floor(Date.now() / 1000);
return new TransactionBuilder(new Account(MERCHANT_ADDRESS, '0'), {
fee: '100',
networkPassphrase: passphrase,
timebounds: { minTime: now - 60, maxTime: now + 300 },
})
.addOperation(Operation.manageData({ name: 'SomeOtherKey', value: 'somevalue' }))
.build();
}

function buildMultiOpTransaction(nonce: string, passphrase = Networks.TESTNET) {
const now = Math.floor(Date.now() / 1000);
return new TransactionBuilder(new Account(MERCHANT_ADDRESS, '0'), {
fee: '100',
networkPassphrase: passphrase,
timebounds: { minTime: now - 60, maxTime: now + 300 },
})
.addOperation(Operation.manageData({ name: 'Accensa Auth', value: nonce }))
.addOperation(Operation.manageData({ name: 'Extra', value: 'data' }))
.build();
}

describe('/api/auth/verify POST', () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.MERCHANT_ADDRESS = MERCHANT_ADDRESS;
process.env.STELLAR_NETWORK_PASSPHRASE = Networks.TESTNET;
mockConsumeNonce.mockResolvedValue(true);
});

const makeRequest = (body: unknown) =>
new Request('http://localhost/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});

test('accepts a valid challenge and issues a session', async () => {
const nonce = 'a'.repeat(64);
const tx = buildChallenge(nonce);
tx.sign(MERCHANT_KEYPAIR);

const res = await POST(makeRequest({ xdr: tx.toXDR() }));

expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
expect(mockConsumeNonce).toHaveBeenCalledWith(expect.anything(), nonce);
expect(mockCreateSession).toHaveBeenCalledWith(MERCHANT_ADDRESS);
});

test('rejects a transaction with no manageData operation', async () => {
const tx = buildNonChallengeTransaction();
tx.sign(MERCHANT_KEYPAIR);

const res = await POST(makeRequest({ xdr: tx.toXDR() }));

expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toBe('Invalid challenge structure');
expect(mockConsumeNonce).not.toHaveBeenCalled();
});

test('rejects a transaction with multiple operations', async () => {
const nonce = 'b'.repeat(64);
const tx = buildMultiOpTransaction(nonce);
tx.sign(MERCHANT_KEYPAIR);

const res = await POST(makeRequest({ xdr: tx.toXDR() }));

expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toBe('Invalid challenge structure');
});

test('rejects a challenge with a nonce the server never issued', async () => {
const nonce = 'c'.repeat(64);
mockConsumeNonce.mockResolvedValue(false);

const tx = buildChallenge(nonce);
tx.sign(MERCHANT_KEYPAIR);

const res = await POST(makeRequest({ xdr: tx.toXDR() }));

expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toBe('Invalid or reused nonce');
});

test('rejects the same valid challenge twice (replay protection)', async () => {
const nonce = 'd'.repeat(64);
const tx = buildChallenge(nonce);
tx.sign(MERCHANT_KEYPAIR);
const xdr = tx.toXDR();

// First attempt succeeds
mockConsumeNonce.mockResolvedValueOnce(true);
const res1 = await POST(makeRequest({ xdr }));
expect(res1.status).toBe(200);

// Second attempt fails because the nonce is already consumed
mockConsumeNonce.mockResolvedValueOnce(false);
const res2 = await POST(makeRequest({ xdr }));
expect(res2.status).toBe(401);
expect(await res2.json()).toMatchObject({
error: 'Invalid or reused nonce',
});
});

test('rejects a challenge after maxTime has passed', async () => {
const now = Math.floor(Date.now() / 1000);
const tx = new TransactionBuilder(new Account(MERCHANT_ADDRESS, '0'), {
fee: '100',
networkPassphrase: Networks.TESTNET,
timebounds: { minTime: now - 120, maxTime: now - 60 },
})
.addOperation(Operation.manageData({ name: 'Accensa Auth', value: 'e'.repeat(64) }))
.build();
tx.sign(MERCHANT_KEYPAIR);

const res = await POST(makeRequest({ xdr: tx.toXDR() }));

expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toBe('Challenge expired or invalid');
});

test('returns 500 when MERCHANT_ADDRESS is not configured', async () => {
delete process.env.MERCHANT_ADDRESS;
const res = await POST(makeRequest({ xdr: 'anything' }));
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe('MERCHANT_ADDRESS not configured');
});

test('returns 400 when xdr is missing', async () => {
const res = await POST(makeRequest({}));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe('Missing xdr');
});

test('uses the configured network passphrase for parsing', async () => {
process.env.STELLAR_NETWORK_PASSPHRASE = Networks.PUBLIC;
const nonce = 'f'.repeat(64);
const tx = buildChallenge(nonce, Networks.PUBLIC);
tx.sign(MERCHANT_KEYPAIR);

const res = await POST(makeRequest({ xdr: tx.toXDR() }));

expect(res.status).toBe(200);
const data = await res.json();
expect(data.success).toBe(true);
});
});
36 changes: 34 additions & 2 deletions apps/web/src/app/api/auth/verify/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { NextResponse } from 'next/server';
import { Transaction, Networks, Keypair } from '@stellar/stellar-sdk';
import { createSession } from '@/lib/auth';
import { withClient, ensureSchema, consumeNonce } from '@/lib/db';

function networkPassphrase(): string {
return process.env.STELLAR_NETWORK_PASSPHRASE ?? Networks.TESTNET;
}

export async function POST(request: Request) {
const merchantAddress = process.env.MERCHANT_ADDRESS;
Expand All @@ -14,7 +19,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Missing xdr' }, { status: 400 });
}

const tx = new Transaction(xdr, Networks.TESTNET);
const tx = new Transaction(xdr, networkPassphrase());

// Validate timebounds
const now = Math.floor(Date.now() / 1000);
Expand All @@ -38,7 +43,34 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}

// Signature is valid. Issue session.
// Verify the transaction contains exactly one manageData operation
// with key "Accensa Auth" and a nonce this server issued.
if (tx.operations.length !== 1) {
return NextResponse.json({ error: 'Invalid challenge structure' }, { status: 401 });
}

const op = tx.operations[0];
if (op.type !== 'manageData') {
return NextResponse.json({ error: 'Invalid challenge structure' }, { status: 401 });
}

if (op.name !== 'Accensa Auth' || !op.value) {
return NextResponse.json({ error: 'Invalid challenge structure' }, { status: 401 });
}

const nonce = typeof op.value === 'string' ? op.value : Buffer.from(op.value).toString('utf8');

// Confirm the nonce was issued by this server and consume it
const consumed = await withClient(async (client) => {
await ensureSchema(client);
return consumeNonce(client, nonce);
});

if (!consumed) {
return NextResponse.json({ error: 'Invalid or reused nonce' }, { status: 401 });
}

// Signature is valid, challenge structure is correct, nonce is consumed. Issue session.
await createSession(merchantAddress);

return NextResponse.json({ success: true });
Expand Down
Loading
Loading