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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "AuthNonce_address_idx" ON "AuthNonce"("address");

-- Enforce at most one unused (active) nonce per wallet address.
CREATE UNIQUE INDEX "AuthNonce_address_active_key" ON "AuthNonce"("address") WHERE "usedAt" IS NULL;
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ model AuthNonce {
createdAt DateTime @default(now())
merchantId String?
merchant Merchant? @relation(fields: [merchantId], references: [id])

@@index([address])
// Enforced in migration: unique (address) WHERE usedAt IS NULL
}

model RefreshToken {
Expand Down
22 changes: 22 additions & 0 deletions src/controllers/auth.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { StrKey } from '@stellar/stellar-sdk';
import { createNonce, authenticateWallet } from '../services/auth.services.js';
import { resendEmailOtp, verifyEmailOtp } from '../services/otp.services.js';
import { sanitizeMerchant } from '../services/merchant.services.js';
Expand All @@ -18,6 +19,27 @@ export const createNonceController = async (req: Request, res: Response) => {
}
};

export const createChallengeController = async (req: Request, res: Response) => {
try {
const { address } = req.body ?? {};
if (!address || typeof address !== 'string' || !StrKey.isValidEd25519PublicKey(address)) {
res.status(400).json({ error: 'Invalid Stellar address' });
return;
}

const result = await createNonce(address);
res.status(200).json(result);
} catch (error) {
console.error('Failed to create auth challenge', {
path: req.path,
method: req.method,
address: typeof req.body?.address === 'string' ? req.body.address : undefined,
error: error instanceof Error ? error.message : 'Unknown error',
});
res.status(500).json({ error: 'Internal Server Error' });
}
};

export const verifySignatureController = async (req: Request, res: Response) => {
try {
const { address, nonce, signature } = req.body;
Expand Down
2 changes: 2 additions & 0 deletions src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Router } from 'express';
import {
createNonceController,
createChallengeController,
verifySignatureController,
verifyEmailController,
resendOtpController,
Expand All @@ -9,6 +10,7 @@ import { authenticateMerchant } from '../middlewares/auth.middleware.js';

const router = Router();

router.post('/challenge', createChallengeController);
router.post('/nonce', createNonceController);
router.post('/verify', verifySignatureController);
router.post('/verify-email', authenticateMerchant, verifyEmailController);
Expand Down
75 changes: 64 additions & 11 deletions src/services/auth.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,51 @@ import { environment } from '../config/environment.js';

const NONCE_EXPIRY_MS = 5 * 60 * 1000;
const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_NONCE_CREATE_ATTEMPTS = 3;

function formatChallengeResponse(authNonce: { message: string; nonce: string; expiresAt: Date }) {
return {
message: authNonce.message,
nonce: authNonce.nonce,
expiresAt: authNonce.expiresAt,
};
}

function isActiveNonceConflict(error: unknown): boolean {
return (error as { code?: string })?.code === 'P2002';
}

async function findActiveNonceForAddress(address: string, now: Date) {
return prisma.authNonce.findFirst({
where: {
address,
usedAt: null,
expiresAt: { gt: now },
},
orderBy: { createdAt: 'desc' },
});
}

async function createNonceInTransaction(address: string, now: Date) {
const nonce = crypto.randomBytes(32).toString('hex');
const createdAt = now;
const expiresAt = new Date(createdAt.getTime() + NONCE_EXPIRY_MS);
const message = buildChallengeMessage(address, nonce, createdAt);

return prisma.$transaction(async tx => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${address}))`;

await tx.authNonce.deleteMany({
where: {
OR: [{ expiresAt: { lt: now } }, { address, usedAt: null }],
},
});

return tx.authNonce.create({
data: { address, nonce, message, expiresAt },
});
});
}

export function buildChallengeMessage(address: string, nonce: string, createdAt: Date): string {
return [
Expand All @@ -17,16 +62,25 @@ export function buildChallengeMessage(address: string, nonce: string, createdAt:
}

export async function createNonce(address: string) {
const nonce = crypto.randomUUID();
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + NONCE_EXPIRY_MS);
const message = buildChallengeMessage(address, nonce, createdAt);

const authNonce = await prisma.authNonce.create({
data: { address, nonce, message, expiresAt },
});
const now = new Date();

for (let attempt = 0; attempt < MAX_NONCE_CREATE_ATTEMPTS; attempt++) {
try {
const authNonce = await createNonceInTransaction(address, now);
return formatChallengeResponse(authNonce);
} catch (error) {
if (!isActiveNonceConflict(error)) {
throw error;
}

const existing = await findActiveNonceForAddress(address, now);
if (existing) {
return formatChallengeResponse(existing);
}
}
}

return { nonce: authNonce.nonce, message: authNonce.message, expiresAt: authNonce.expiresAt };
throw new Error('Failed to create auth challenge after concurrent conflict');
}

export async function verifySignature(address: string, nonce: string, rawSignature: string) {
Expand All @@ -44,8 +98,7 @@ export async function verifySignature(address: string, nonce: string, rawSignatu
return { valid: false, reason: 'Nonce expired' } as const;
}

const message = buildChallengeMessage(address, authNonce.nonce, authNonce.createdAt);
const messageBytes = Buffer.from(message, 'utf-8');
const messageBytes = Buffer.from(authNonce.message, 'utf-8');
const signatureBytes = Buffer.from(rawSignature, 'hex');

let isValid: boolean;
Expand Down
130 changes: 130 additions & 0 deletions tests/integration/auth.challenge.concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import crypto from 'node:crypto';
import { jest, beforeEach } from '@jest/globals';
import { mockReset } from 'jest-mock-extended';

type StoredNonce = {
id: string;
address: string;
nonce: string;
message: string;
expiresAt: Date;
usedAt: Date | null;
createdAt: Date;
merchantId: string | null;
};

const mockDate = new Date('2026-06-21T12:00:00Z');
const address = 'GABCDEF1234567890123456789012345678901234567890123456789012';

const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any;
const { createNonce } = await import('../../src/services/auth.services.js');

function createAddressLock() {
const tails = new Map<string, Promise<void>>();

return async function withAddressLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = tails.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>(resolve => {
release = resolve;
});
tails.set(
key,
previous.then(() => current),
);

await previous;
try {
return await fn();
} finally {
release();
}
};
}

describe('createNonce concurrency (database-backed simulation)', () => {
let store: StoredNonce[];
let withAddressLock: ReturnType<typeof createAddressLock>;

beforeEach(() => {
mockReset(prismaMock);
jest.useFakeTimers({ now: mockDate });
store = [];
withAddressLock = createAddressLock();

prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => unknown) =>
withAddressLock(address, () => callback(prismaMock)),
);

prismaMock.$executeRaw.mockResolvedValue(1);

prismaMock.authNonce.deleteMany.mockImplementation(async () => {
const now = mockDate;
const before = store.length;
store = store.filter(record => {
const isExpired = record.expiresAt < now;
const isActiveUnusedForAddress = record.address === address && record.usedAt === null;
return !(isExpired || isActiveUnusedForAddress);
});
return { count: before - store.length };
});

prismaMock.authNonce.create.mockImplementation(async ({ data }: { data: StoredNonce }) => {
const conflict = store.some(
record => record.address === data.address && record.usedAt === null && record.expiresAt >= mockDate,
);
if (conflict) {
throw { code: 'P2002', meta: { target: ['address'] } };
}

const record: StoredNonce = {
id: crypto.randomUUID(),
merchantId: null,
usedAt: null,
createdAt: mockDate,
...data,
};
store.push(record);
return record;
});

prismaMock.authNonce.findFirst.mockImplementation(
async ({
where,
}: {
where: { address: string; usedAt: null; expiresAt: { gt: Date } };
}) => {
return (
store.find(
record =>
record.address === where.address &&
record.usedAt === null &&
record.expiresAt > where.expiresAt.gt,
) ?? null
);
},
);
});

afterEach(() => {
jest.useRealTimers();
});

test('parallel same-address requests all resolve with a defined challenge response', async () => {
const results = await Promise.all(Array.from({ length: 5 }, () => createNonce(address)));

expect(results).toHaveLength(5);
for (const result of results) {
expect(result).toMatchObject({
message: expect.stringContaining('Shade Authentication'),
nonce: expect.stringMatching(/^[0-9a-f]{64}$/),
expiresAt: expect.any(Date),
});
}

const activeNonces = store.filter(
record => record.address === address && record.usedAt === null && record.expiresAt >= mockDate,
);
expect(activeNonces).toHaveLength(1);
});
});
80 changes: 80 additions & 0 deletions tests/integration/auth.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ jest.unstable_mockModule('@stellar/stellar-sdk', () => ({
};
},
},
StrKey: {
isValidEd25519PublicKey: (address: string) =>
typeof address === 'string' && /^G[A-Z0-9]{55}$/.test(address),
},
}));

const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any;
Expand All @@ -32,12 +36,88 @@ describe('Auth Routes', () => {
jest.useFakeTimers({ now: mockDate });
mockVerify.returns = true;
mockKeypairError.throws = false;
prismaMock.$transaction.mockImplementation(
async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock),
);
prismaMock.$executeRaw.mockResolvedValue(1);
});

afterEach(() => {
jest.useRealTimers();
});

describe('POST /api/v1/auth/challenge', () => {
const validAddress = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF';

test('should return 200 with message, nonce, and expiresAt for a valid Stellar address', async () => {
const generatedNonce = 'ab'.repeat(32);
const message = [
'Shade Authentication',
`Address: ${validAddress}`,
`Nonce: ${generatedNonce}`,
'Timestamp: 2026-06-21T12:00:00.000Z',
].join('\n');
const expiresAt = new Date('2026-06-21T12:05:00.000Z');

prismaMock.authNonce.deleteMany.mockResolvedValue({ count: 0 });
prismaMock.authNonce.create.mockResolvedValue({
id: 'uuid-1',
address: validAddress,
nonce: generatedNonce,
message,
expiresAt,
usedAt: null,
createdAt: mockDate,
merchantId: null,
});

const response = await request(app)
.post('/api/v1/auth/challenge')
.send({ address: validAddress });

expect(response.status).toBe(200);
expect(response.body).toEqual({
message,
nonce: generatedNonce,
expiresAt: expiresAt.toISOString(),
});
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1);
expect(prismaMock.authNonce.deleteMany).toHaveBeenCalledWith({
where: {
OR: [
{ expiresAt: { lt: mockDate } },
{ address: validAddress, usedAt: null },
],
},
});
expect(prismaMock.authNonce.create).toHaveBeenCalledWith({
data: expect.objectContaining({
address: validAddress,
nonce: expect.stringMatching(/^[0-9a-f]{64}$/),
message: expect.stringContaining('Shade Authentication'),
expiresAt: expect.any(Date),
}),
});
});

test('should return 400 for an invalid Stellar address', async () => {
const response = await request(app)
.post('/api/v1/auth/challenge')
.send({ address: 'not-a-stellar-address' });

expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Invalid Stellar address' });
expect(prismaMock.authNonce.create).not.toHaveBeenCalled();
});

test('should return 400 when address is missing', async () => {
const response = await request(app).post('/api/v1/auth/challenge').send({});

expect(response.status).toBe(400);
expect(response.body).toEqual({ error: 'Invalid Stellar address' });
});
});

describe('POST /api/v1/auth/verify', () => {
const mockAuthNonce = {
id: 'uuid-1',
Expand Down
Loading
Loading