Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
26 changes: 19 additions & 7 deletions src/services/auth.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,29 @@ export function buildChallengeMessage(address: string, nonce: string, createdAt:
}

export async function createNonce(address: string) {
const nonce = crypto.randomUUID();
const createdAt = new Date();
const now = new 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);

const authNonce = await prisma.authNonce.create({
data: { address, nonce, message, expiresAt },
const authNonce = await prisma.$transaction(async tx => {
await tx.authNonce.deleteMany({
where: {
OR: [{ expiresAt: { lt: now } }, { address, usedAt: null }],
},
});

return tx.authNonce.create({
data: { address, nonce, message, expiresAt },
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

return { nonce: authNonce.nonce, message: authNonce.message, expiresAt: authNonce.expiresAt };
return {
message: authNonce.message,
nonce: authNonce.nonce,
expiresAt: authNonce.expiresAt,
};
}

export async function verifySignature(address: string, nonce: string, rawSignature: string) {
Expand All @@ -44,8 +57,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
79 changes: 79 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,87 @@ describe('Auth Routes', () => {
jest.useFakeTimers({ now: mockDate });
mockVerify.returns = true;
mockKeypairError.throws = false;
prismaMock.$transaction.mockImplementation(
async (callback: (tx: typeof prismaMock) => unknown) => callback(prismaMock),
);
});

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
83 changes: 74 additions & 9 deletions tests/unit/auth.services.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand All @@ -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;
Expand All @@ -35,7 +42,11 @@ 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),
);
});

afterEach(() => {
Expand All @@ -52,35 +63,71 @@ 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);

const result = await createNonce('GABCDEF123');

expect(prismaMock.$transaction).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('should serialize concurrent requests through a single transaction callback', async () => {
const mockNonce = {
id: 'uuid-1',
address: 'GABCDEF123',
nonce: 'b'.repeat(64),
message: 'stored-message',
expiresAt: new Date('2026-06-21T12:05:00.000Z'),
usedAt: null,
createdAt: mockDate,
};

prismaMock.authNonce.deleteMany.mockResolvedValue({ count: 1 });
prismaMock.authNonce.create.mockResolvedValue(mockNonce);

await Promise.all([createNonce('GABCDEF123'), createNonce('GABCDEF123')]);

expect(prismaMock.$transaction).toHaveBeenCalledTimes(2);
expect(prismaMock.authNonce.deleteMany).toHaveBeenCalledTimes(2);
expect(prismaMock.authNonce.create).toHaveBeenCalledTimes(2);
});
});

Expand Down Expand Up @@ -111,6 +158,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);

Expand Down
Loading
Loading