Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
16 changes: 16 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,21 @@ 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 {
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
12 changes: 10 additions & 2 deletions src/services/auth.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export function buildChallengeMessage(address: string, nonce: string, createdAt:
}

export async function createNonce(address: string) {
const nonce = crypto.randomUUID();
await prisma.authNonce.deleteMany({
where: { expiresAt: { lt: new Date() } },
});

const nonce = crypto.randomBytes(32).toString('hex');
const createdAt = new Date();
const expiresAt = new Date(createdAt.getTime() + NONCE_EXPIRY_MS);
const message = buildChallengeMessage(address, nonce, createdAt);
Expand All @@ -26,7 +30,11 @@ export async function createNonce(address: string) {
data: { address, nonce, message, expiresAt },
});

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 Down
70 changes: 70 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 @@ -38,6 +42,72 @@ describe('Auth Routes', () => {
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.authNonce.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: expect.any(Date) } },
});
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
28 changes: 21 additions & 7 deletions tests/unit/auth.services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ jest.unstable_mockModule('@stellar/stellar-sdk', () => ({
};
},
},
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 Down Expand Up @@ -52,34 +56,44 @@ describe('Auth Services', () => {
});

describe('createNonce', () => {
test('should create an AuthNonce record and return nonce, message, and expiresAt', async () => {
test('should clean up expired nonces, create an AuthNonce, and return message, nonce, expiresAt', async () => {
const mockNonce = {
id: 'uuid-1',
address: 'GABCDEF123',
nonce: 'generated-uuid',
message: 'Shade Authentication\nAddress: GABCDEF123\nNonce: generated-uuid\nTimestamp: 2026-06-21T12:00:00.000Z',
nonce: 'a'.repeat(64),
message:
'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.authNonce.deleteMany).toHaveBeenCalledWith({
where: { expiresAt: { lt: expect.any(Date) } },
});
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],
);
});
});

Expand Down
Loading