diff --git a/prisma/migrations/20260729100000_auth_nonce_active_address_unique/migration.sql b/prisma/migrations/20260729100000_auth_nonce_active_address_unique/migration.sql new file mode 100644 index 0000000..c9e6fa0 --- /dev/null +++ b/prisma/migrations/20260729100000_auth_nonce_active_address_unique/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 130ad94..8cc2d26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/src/controllers/auth.controllers.ts b/src/controllers/auth.controllers.ts index 65b4223..456e031 100644 --- a/src/controllers/auth.controllers.ts +++ b/src/controllers/auth.controllers.ts @@ -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'; @@ -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; diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index 62c5d67..607ddf5 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { createNonceController, + createChallengeController, verifySignatureController, verifyEmailController, resendOtpController, @@ -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); diff --git a/src/services/auth.services.ts b/src/services/auth.services.ts index 0594050..23b6615 100644 --- a/src/services/auth.services.ts +++ b/src/services/auth.services.ts @@ -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 [ @@ -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) { @@ -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; diff --git a/tests/integration/auth.challenge.concurrency.test.ts b/tests/integration/auth.challenge.concurrency.test.ts new file mode 100644 index 0000000..eb516b2 --- /dev/null +++ b/tests/integration/auth.challenge.concurrency.test.ts @@ -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>(); + + return async function withAddressLock(key: string, fn: () => Promise): Promise { + const previous = tails.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise(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; + + 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); + }); +}); diff --git a/tests/integration/auth.routes.test.ts b/tests/integration/auth.routes.test.ts index 2ca4b2b..b4b868c 100644 --- a/tests/integration/auth.routes.test.ts +++ b/tests/integration/auth.routes.test.ts @@ -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; @@ -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', diff --git a/tests/unit/auth.services.test.ts b/tests/unit/auth.services.test.ts index 3310385..46af704 100644 --- a/tests/unit/auth.services.test.ts +++ b/tests/unit/auth.services.test.ts @@ -1,7 +1,7 @@ import { jest, beforeEach } from '@jest/globals'; import { mockReset } from 'jest-mock-extended'; -const mockVerify = { returns: true }; +const mockVerify = { returns: true, lastMessage: undefined as string | undefined }; const mockKeypairError = { throws: false }; jest.unstable_mockModule('@stellar/stellar-sdk', () => ({ @@ -11,10 +11,17 @@ jest.unstable_mockModule('@stellar/stellar-sdk', () => ({ throw new Error('invalid public key'); } return { - verify: () => mockVerify.returns, + verify: (messageBytes: Buffer) => { + mockVerify.lastMessage = messageBytes.toString('utf-8'); + return mockVerify.returns; + }, }; }, }, + StrKey: { + isValidEd25519PublicKey: (address: string) => + typeof address === 'string' && address.startsWith('G') && address.length >= 56, + }, })); const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; @@ -35,7 +42,12 @@ describe('Auth Services', () => { mockReset(prismaMock); jest.useFakeTimers({ now: mockDate }); mockVerify.returns = true; + mockVerify.lastMessage = undefined; mockKeypairError.throws = false; + prismaMock.$transaction.mockImplementation( + async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock), + ); + prismaMock.$executeRaw.mockResolvedValue(1); }); afterEach(() => { @@ -52,35 +64,114 @@ describe('Auth Services', () => { }); describe('createNonce', () => { - test('should create an AuthNonce record and return nonce, message, and expiresAt', async () => { + test('should run cleanup and creation inside a transaction', async () => { const mockNonce = { id: 'uuid-1', address: 'GABCDEF123', - nonce: 'generated-uuid', + nonce: 'a'.repeat(64), message: - 'Shade Authentication\nAddress: GABCDEF123\nNonce: generated-uuid\nTimestamp: 2026-06-21T12:00:00.000Z', + 'Shade Authentication\nAddress: GABCDEF123\nNonce: ' + + 'a'.repeat(64) + + '\nTimestamp: 2026-06-21T12:00:00.000Z', expiresAt: new Date('2026-06-21T12:05:00.000Z'), usedAt: null, createdAt: mockDate, }; + prismaMock.authNonce.deleteMany.mockResolvedValue({ count: 1 }); prismaMock.authNonce.create.mockResolvedValue(mockNonce); + prismaMock.$executeRaw.mockResolvedValue(1); const result = await createNonce('GABCDEF123'); + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1); + expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1); + expect(prismaMock.authNonce.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [ + { expiresAt: { lt: mockDate } }, + { address: 'GABCDEF123', usedAt: null }, + ], + }, + }); expect(result).toEqual({ - nonce: mockNonce.nonce, message: mockNonce.message, + nonce: mockNonce.nonce, expiresAt: mockNonce.expiresAt, }); expect(prismaMock.authNonce.create).toHaveBeenCalledWith({ data: expect.objectContaining({ address: 'GABCDEF123', - nonce: expect.any(String), - message: expect.any(String), - expiresAt: expect.any(Date), + nonce: expect.stringMatching(/^[0-9a-f]{64}$/), + message: expect.stringContaining('Shade Authentication'), + expiresAt: new Date('2026-06-21T12:05:00.000Z'), }), }); + expect(prismaMock.authNonce.deleteMany.mock.invocationCallOrder[0]).toBeLessThan( + prismaMock.authNonce.create.mock.invocationCallOrder[0], + ); + }); + + test('returns an existing active challenge when the unique constraint is violated', async () => { + const existing = { + id: 'uuid-existing', + address: 'GABCDEF123', + nonce: 'e'.repeat(64), + message: 'stored-existing-message', + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null, + createdAt: mockDate, + merchantId: null, + }; + + prismaMock.$executeRaw.mockResolvedValue(1); + prismaMock.authNonce.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.authNonce.create.mockRejectedValue({ code: 'P2002', meta: { target: ['address'] } }); + prismaMock.authNonce.findFirst.mockResolvedValue(existing); + + const result = await createNonce('GABCDEF123'); + + expect(result).toEqual({ + message: existing.message, + nonce: existing.nonce, + expiresAt: existing.expiresAt, + }); + expect(prismaMock.authNonce.findFirst).toHaveBeenCalledWith({ + where: { + address: 'GABCDEF123', + usedAt: null, + expiresAt: { gt: mockDate }, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + test('retries issuance when a unique constraint conflict has no readable active nonce', async () => { + const mockNonce = { + id: 'uuid-1', + address: 'GABCDEF123', + nonce: 'f'.repeat(64), + message: 'retry-message', + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null, + createdAt: mockDate, + }; + + prismaMock.$executeRaw.mockResolvedValue(1); + prismaMock.authNonce.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.authNonce.create + .mockRejectedValueOnce({ code: 'P2002', meta: { target: ['address'] } }) + .mockResolvedValueOnce(mockNonce); + prismaMock.authNonce.findFirst.mockResolvedValueOnce(null); + + const result = await createNonce('GABCDEF123'); + + expect(prismaMock.$transaction).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + message: mockNonce.message, + nonce: mockNonce.nonce, + expiresAt: mockNonce.expiresAt, + }); }); }); @@ -111,6 +202,24 @@ describe('Auth Services', () => { }); }); + test('should verify using the stored message instead of reconstructing from createdAt', async () => { + const storedMessage = + 'Shade Authentication\nAddress: GABCDEF123\nNonce: nonce-abc\nTimestamp: 2026-01-01T00:00:00.000Z'; + + prismaMock.authNonce.findUnique.mockResolvedValue({ + ...mockAuthNonce, + message: storedMessage, + createdAt: new Date('2026-06-21T12:00:00.000Z'), + }); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + + const result = await verifySignature(address, nonce, signature); + + expect(result).toEqual({ valid: true, reason: null }); + expect(mockVerify.lastMessage).toBe(storedMessage); + expect(mockVerify.lastMessage).not.toBe(buildChallengeMessage(address, nonce, mockDate)); + }); + test('should return invalid when nonce is not found', async () => { prismaMock.authNonce.findUnique.mockResolvedValue(null); diff --git a/tests/unit/auth.sign-verify.e2e.test.ts b/tests/unit/auth.sign-verify.e2e.test.ts new file mode 100644 index 0000000..7e95577 --- /dev/null +++ b/tests/unit/auth.sign-verify.e2e.test.ts @@ -0,0 +1,108 @@ +import { jest, beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import { Keypair } from '@stellar/stellar-sdk'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { + buildChallengeMessage, + createNonce, + verifySignature, + authenticateWallet, +} = await import('../../src/services/auth.services.js'); + +const mockDate = new Date('2026-06-21T12:00:00Z'); + +describe('Auth sign/verify E2E', () => { + const keypair = Keypair.random(); + const address = keypair.publicKey(); + + beforeEach(() => { + mockReset(prismaMock); + jest.useFakeTimers({ now: mockDate }); + prismaMock.$transaction.mockImplementation( + async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock), + ); + prismaMock.$executeRaw.mockResolvedValue(1); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('wallet-signed challenge verifies against the stored message', async () => { + const nonce = 'c'.repeat(64); + const message = buildChallengeMessage(address, nonce, mockDate); + const signature = keypair.sign(Buffer.from(message, 'utf-8')).toString('hex'); + const authNonceRecord = { + id: 'uuid-1', + address, + nonce, + message, + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null, + createdAt: mockDate, + merchantId: null, + }; + + prismaMock.authNonce.findUnique.mockResolvedValue(authNonceRecord); + prismaMock.authNonce.update.mockResolvedValue(authNonceRecord); + + const result = await verifySignature(address, nonce, signature); + + expect(result).toEqual({ valid: true, reason: null }); + }); + + test('challenge -> sign -> verify issues tokens for a new merchant', async () => { + prismaMock.authNonce.deleteMany.mockResolvedValue({ count: 0 }); + prismaMock.authNonce.create.mockImplementation(async ({ data }: { data: Record }) => ({ + id: 'uuid-1', + ...data, + usedAt: null, + createdAt: mockDate, + merchantId: null, + })); + prismaMock.merchant.findFirst.mockResolvedValue(null); + prismaMock.merchant.create.mockResolvedValue({ + id: 'merchant-uuid', + merchantId: 123456, + address, + registered: false, + }); + prismaMock.refreshToken.create.mockResolvedValue({ + id: 'session-uuid', + merchantId: 'merchant-uuid', + token: 'refresh-token', + expiresAt: new Date('2026-06-28T12:00:00.000Z'), + createdAt: mockDate, + }); + + const challenge = await createNonce(address); + const signature = keypair.sign(Buffer.from(challenge.message, 'utf-8')).toString('hex'); + const authNonceRecord = { + id: 'uuid-1', + address, + nonce: challenge.nonce, + message: challenge.message, + expiresAt: challenge.expiresAt, + usedAt: null, + createdAt: mockDate, + merchantId: null, + }; + + prismaMock.authNonce.findUnique.mockResolvedValue(authNonceRecord); + prismaMock.authNonce.update.mockResolvedValue(authNonceRecord); + + const authResult = await authenticateWallet(address, challenge.nonce, signature); + + expect(authResult.success).toBe(true); + if (authResult.success) { + expect(authResult.accessToken).toBeTruthy(); + expect(authResult.refreshToken).toBeTruthy(); + expect(authResult.merchant).toEqual({ + id: 'merchant-uuid', + address, + isRegistered: false, + }); + } + }); +});