From baffe91e8f0fb60ff25c1fdf1c4431d2703689b3 Mon Sep 17 00:00:00 2001 From: dnnyorji Date: Fri, 21 Aug 2026 04:49:37 +0100 Subject: [PATCH 1/4] feat(db): add admin activation and refresh token schema Adds Admin.active/isSuperAdmin flags and an AdminRefreshToken model (mirroring RefreshToken) so admin sessions can be tracked and revoked independently of merchant sessions. --- .../migration.sql | 20 +++++++++++++++++++ prisma/schema.prisma | 17 ++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20260821000000_add_admin_refresh_token/migration.sql diff --git a/prisma/migrations/20260821000000_add_admin_refresh_token/migration.sql b/prisma/migrations/20260821000000_add_admin_refresh_token/migration.sql new file mode 100644 index 0000000..ecbde44 --- /dev/null +++ b/prisma/migrations/20260821000000_add_admin_refresh_token/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "Admin" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "isSuperAdmin" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "AdminRefreshToken" ( + "id" TEXT NOT NULL, + "adminId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AdminRefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AdminRefreshToken_token_key" ON "AdminRefreshToken"("token"); + +-- AddForeignKey +ALTER TABLE "AdminRefreshToken" ADD CONSTRAINT "AdminRefreshToken_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "Admin"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8cc2d26..f18cfd0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,10 +8,23 @@ datasource db { // Retained: admin-level access control is a separate auth concern handled outside merchant flows. model Admin { + id String @id @default(uuid()) + address String @unique + active Boolean @default(true) + isSuperAdmin Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + refreshTokens AdminRefreshToken[] +} + +model AdminRefreshToken { id String @id @default(uuid()) - address String @unique + adminId String + admin Admin @relation(fields: [adminId], references: [id]) + token String @unique + expiresAt DateTime createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt } model Merchant { From 7a873b842b26ca67e9610290ded0eb94e2deba89 Mon Sep 17 00:00:00 2001 From: dnnyorji Date: Fri, 21 Aug 2026 04:50:06 +0100 Subject: [PATCH 2/4] feat(auth): add admin wallet authentication service and middleware Introduces authenticateAdminWallet, reusing the existing address-generic createNonce/verifySignature from auth.services.ts instead of duplicating nonce handling. Unlike merchant auth, no Admin row is auto-provisioned: a valid signature from an address with no active Admin row is rejected. Issued JWTs carry a `type: 'admin'` claim so admin and merchant tokens can never be confused, even though they share JWT_SECRET. authenticateAdmin enforces that claim and loads req.admin; requireSuperAdmin chains after it to gate superadmin-only routes. --- src/middlewares/admin.middleware.ts | 78 +++++++++ src/services/admin-auth.services.ts | 52 ++++++ src/types/express.d.ts | 3 +- tests/integration/admin.middleware.test.ts | 130 +++++++++++++++ tests/unit/admin-auth.services.test.ts | 182 +++++++++++++++++++++ 5 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 src/middlewares/admin.middleware.ts create mode 100644 src/services/admin-auth.services.ts create mode 100644 tests/integration/admin.middleware.test.ts create mode 100644 tests/unit/admin-auth.services.test.ts diff --git a/src/middlewares/admin.middleware.ts b/src/middlewares/admin.middleware.ts new file mode 100644 index 0000000..e1b5b8a --- /dev/null +++ b/src/middlewares/admin.middleware.ts @@ -0,0 +1,78 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import prisma from '../config/prisma.js'; +import { environment } from '../config/environment.js'; + +const extractBearerToken = (req: Request): string | null => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.slice('Bearer '.length).trim(); + return token || null; +}; + +/** + * Authenticates an admin from a JWT bearer token issued by admin-auth.services.ts. + * + * Rejects tokens missing the `type: 'admin'` claim (including structurally valid + * merchant JWTs), tokens for an unknown admin, and tokens for a deactivated admin. + * The resolved Admin is attached to `req.admin` on success. + */ +export const authenticateAdmin = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + const token = extractBearerToken(req); + + if (!token) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + let payload: { sub?: string; type?: string }; + try { + payload = jwt.verify(token, environment.jwtSecret) as { sub?: string; type?: string }; + } catch { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } + + if (!payload.sub || payload.type !== 'admin') { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } + + const admin = await prisma.admin.findUnique({ where: { id: payload.sub } }); + if (!admin || !admin.active) { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } + + req.admin = admin; + next(); + } catch { + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +/** + * Chained after authenticateAdmin. Rejects an authenticated admin that is not a superadmin. + */ +export const requireSuperAdmin = (req: Request, res: Response, next: NextFunction): void => { + if (!req.admin) { + res.status(401).json({ error: 'Authentication required' }); + return; + } + + if (!req.admin.isSuperAdmin) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + + next(); +}; diff --git a/src/services/admin-auth.services.ts b/src/services/admin-auth.services.ts new file mode 100644 index 0000000..a8b7900 --- /dev/null +++ b/src/services/admin-auth.services.ts @@ -0,0 +1,52 @@ +import crypto from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import prisma from '../config/prisma.js'; +import { environment } from '../config/environment.js'; +import { verifySignature } from './auth.services.js'; + +const REFRESH_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; + +export function issueAdminAccessToken(adminId: string, address: string): string { + return jwt.sign({ sub: adminId, address, type: 'admin' }, environment.jwtSecret, { + expiresIn: '15m', + }); +} + +export async function issueAdminRefreshToken(adminId: string): Promise { + const token = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_MS); + + await prisma.adminRefreshToken.create({ + data: { adminId, token, expiresAt }, + }); + + return token; +} + +export async function authenticateAdminWallet(address: string, nonce: string, signature: string) { + const verification = await verifySignature(address, nonce, signature); + if (!verification.valid) { + return { success: false, reason: verification.reason } as const; + } + + const admin = await prisma.admin.findUnique({ where: { address } }); + if (!admin || !admin.active) { + // TODO: record an audit log entry for this failed login attempt once recordAuditLog lands. + return { success: false, reason: 'Not an admin' } as const; + } + + const accessToken = issueAdminAccessToken(admin.id, admin.address); + const refreshToken = await issueAdminRefreshToken(admin.id); + + // TODO: record an audit log entry for this successful login once recordAuditLog lands. + return { + success: true, + accessToken, + refreshToken, + admin: { + id: admin.id, + address: admin.address, + isSuperAdmin: admin.isSuperAdmin, + }, + } as const; +} diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 8f016c6..4f4ea06 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,9 +1,10 @@ -import type { Merchant } from '@prisma/client'; +import type { Merchant, Admin } from '@prisma/client'; declare global { namespace Express { interface Request { merchant?: Merchant; + admin?: Admin; } } } diff --git a/tests/integration/admin.middleware.test.ts b/tests/integration/admin.middleware.test.ts new file mode 100644 index 0000000..61739fd --- /dev/null +++ b/tests/integration/admin.middleware.test.ts @@ -0,0 +1,130 @@ +import { beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; + +const { default: prismaMock } = (await import('../../src/config/prisma.js')) as any; +const { environment } = await import('../../src/config/environment.js'); +const { authenticateAdmin, requireSuperAdmin } = await import( + '../../src/middlewares/admin.middleware.js' +); + +const admin = { + id: 'admin-uuid', + address: 'GABCDEF123', + active: true, + isSuperAdmin: false, + createdAt: new Date('2026-06-27T12:00:00.000Z'), + updatedAt: new Date('2026-06-27T12:00:00.000Z'), +}; + +const buildApp = () => { + const app = express(); + app.get('/protected', authenticateAdmin, (req, res) => { + res.status(200).json({ adminId: req.admin?.id }); + }); + app.get('/super-only', authenticateAdmin, requireSuperAdmin, (_req, res) => { + res.status(200).json({ ok: true }); + }); + return app; +}; + +const adminToken = (overrides: Record = {}) => + jwt.sign({ sub: admin.id, address: admin.address, type: 'admin', ...overrides }, environment.jwtSecret, { + expiresIn: '15m', + }); + +describe('authenticateAdmin', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('accepts a valid admin JWT and attaches req.admin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(admin); + + const response = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ adminId: admin.id }); + expect(prismaMock.admin.findUnique).toHaveBeenCalledWith({ where: { id: admin.id } }); + }); + + test('rejects a structurally valid merchant JWT missing the admin claim', async () => { + const merchantToken = jwt.sign( + { sub: admin.id, address: admin.address }, + environment.jwtSecret, + { expiresIn: '15m' }, + ); + + const response = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${merchantToken}`); + + expect(response.status).toBe(401); + expect(prismaMock.admin.findUnique).not.toHaveBeenCalled(); + }); + + test('rejects a token for an unknown admin', async () => { + prismaMock.admin.findUnique.mockResolvedValue(null); + + const response = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(401); + }); + + test('rejects a token for a deactivated admin', async () => { + prismaMock.admin.findUnique.mockResolvedValue({ ...admin, active: false }); + + const response = await request(buildApp()) + .get('/protected') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(401); + }); + + test('rejects when the Authorization header is missing', async () => { + const response = await request(buildApp()).get('/protected'); + + expect(response.status).toBe(401); + }); + + test('rejects a garbage token', async () => { + const response = await request(buildApp()) + .get('/protected') + .set('Authorization', 'Bearer not-a-jwt'); + + expect(response.status).toBe(401); + }); +}); + +describe('requireSuperAdmin', () => { + beforeEach(() => { + mockReset(prismaMock); + }); + + test('rejects a non-superadmin authenticated admin with 403', async () => { + prismaMock.admin.findUnique.mockResolvedValue({ ...admin, isSuperAdmin: false }); + + const response = await request(buildApp()) + .get('/super-only') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(403); + }); + + test('allows a superadmin through', async () => { + prismaMock.admin.findUnique.mockResolvedValue({ ...admin, isSuperAdmin: true }); + + const response = await request(buildApp()) + .get('/super-only') + .set('Authorization', `Bearer ${adminToken()}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ ok: true }); + }); +}); diff --git a/tests/unit/admin-auth.services.test.ts b/tests/unit/admin-auth.services.test.ts new file mode 100644 index 0000000..0147e61 --- /dev/null +++ b/tests/unit/admin-auth.services.test.ts @@ -0,0 +1,182 @@ +import { jest, beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; + +const mockVerify = { returns: true }; +const mockKeypairError = { throws: false }; + +jest.unstable_mockModule('@stellar/stellar-sdk', () => ({ + Keypair: { + fromPublicKey: () => { + if (mockKeypairError.throws) { + throw new Error('invalid public key'); + } + return { + verify: () => 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; +const { environment } = await import('../../src/config/environment.js'); +const { buildChallengeMessage } = await import('../../src/services/auth.services.js'); +const { authenticateAdminWallet, issueAdminAccessToken, issueAdminRefreshToken } = await import( + '../../src/services/admin-auth.services.js' +); + +const mockDate = new Date('2026-06-21T12:00:00Z'); + +describe('Admin Auth Services', () => { + beforeEach(() => { + mockReset(prismaMock); + jest.useFakeTimers({ now: mockDate }); + mockVerify.returns = true; + mockKeypairError.throws = false; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('issueAdminAccessToken', () => { + test('should sign a JWT with sub, address, and type: admin claims', async () => { + const token = issueAdminAccessToken('admin-uuid', 'GABCDEF123'); + expect(typeof token).toBe('string'); + expect(token.split('.')).toHaveLength(3); + + const jwt = await import('jsonwebtoken'); + const decoded = jwt.default.verify(token, environment.jwtSecret); + expect(decoded).toMatchObject({ + sub: 'admin-uuid', + address: 'GABCDEF123', + type: 'admin', + }); + }); + }); + + describe('issueAdminRefreshToken', () => { + test('should create an AdminRefreshToken and return the token', async () => { + prismaMock.adminRefreshToken.create.mockResolvedValue({ + id: 'session-uuid', + adminId: 'admin-uuid', + token: 'ignored', + expiresAt: new Date('2026-06-28T12:00:00.000Z'), + createdAt: mockDate, + }); + + const result = await issueAdminRefreshToken('admin-uuid'); + + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + expect(prismaMock.adminRefreshToken.create).toHaveBeenCalledWith({ + data: { + adminId: 'admin-uuid', + token: expect.any(String), + expiresAt: expect.any(Date), + }, + }); + }); + }); + + describe('authenticateAdminWallet', () => { + const address = 'GABCDEF123'; + const nonce = 'nonce-abc'; + const signature = 'deadbeef'; + const mockAuthNonce = { + id: 'uuid-1', + address, + nonce, + message: buildChallengeMessage(address, nonce, mockDate), + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null, + createdAt: mockDate, + merchantId: null, + }; + + test('should return tokens and admin on successful auth for an active admin', async () => { + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + prismaMock.admin.findUnique.mockResolvedValue({ + id: 'admin-uuid', + address, + active: true, + isSuperAdmin: false, + createdAt: mockDate, + updatedAt: mockDate, + }); + prismaMock.adminRefreshToken.create.mockResolvedValue({ + id: 'session-uuid', + adminId: 'admin-uuid', + token: 'ignored', + expiresAt: new Date('2026-06-28T12:00:00.000Z'), + createdAt: mockDate, + }); + + const result = await authenticateAdminWallet(address, nonce, signature); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.accessToken).toBeTruthy(); + expect(typeof result.refreshToken).toBe('string'); + expect(result.admin).toEqual({ + id: 'admin-uuid', + address, + isSuperAdmin: false, + }); + } + expect(prismaMock.admin.findUnique).toHaveBeenCalledWith({ where: { address } }); + }); + + test('should fail without creating an Admin row when no Admin exists for the address', async () => { + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + prismaMock.admin.findUnique.mockResolvedValue(null); + + const result = await authenticateAdminWallet(address, nonce, signature); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('Not an admin'); + } + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('should fail for a deactivated admin', async () => { + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + prismaMock.admin.findUnique.mockResolvedValue({ + id: 'admin-uuid', + address, + active: false, + isSuperAdmin: false, + createdAt: mockDate, + updatedAt: mockDate, + }); + + const result = await authenticateAdminWallet(address, nonce, signature); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('Not an admin'); + } + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('should fail without querying Admin when the signature is invalid', async () => { + mockVerify.returns = false; + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + + const result = await authenticateAdminWallet(address, nonce, signature); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.reason).toBe('Signature verification failed'); + } + expect(prismaMock.admin.findUnique).not.toHaveBeenCalled(); + }); + }); +}); From 211cef60430b677b3671c7597d975fa983df9ae8 Mon Sep 17 00:00:00 2001 From: dnnyorji Date: Fri, 21 Aug 2026 04:50:28 +0100 Subject: [PATCH 3/4] feat(auth): wire up admin auth routes and controllers Mirrors createChallengeController/verifySignatureController to add POST /admin/auth/challenge and POST /admin/auth/verify, mounted under /admin at src/routes/admin/index.ts. This establishes the layout later issues will extend with sibling admin sub-routers (merchant, invoice, etc.), each mounted behind authenticateAdmin. --- src/controllers/admin-auth.controllers.ts | 54 ++++++ src/routes/admin/auth.routes.ts | 12 ++ src/routes/admin/index.ts | 12 ++ src/routes/index.ts | 2 + tests/integration/admin.routes.test.ts | 194 ++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 src/controllers/admin-auth.controllers.ts create mode 100644 src/routes/admin/auth.routes.ts create mode 100644 src/routes/admin/index.ts create mode 100644 tests/integration/admin.routes.test.ts diff --git a/src/controllers/admin-auth.controllers.ts b/src/controllers/admin-auth.controllers.ts new file mode 100644 index 0000000..8a2c4f0 --- /dev/null +++ b/src/controllers/admin-auth.controllers.ts @@ -0,0 +1,54 @@ +import { Request, Response } from 'express'; +import { StrKey } from '@stellar/stellar-sdk'; +import { createNonce } from '../services/auth.services.js'; +import { authenticateAdminWallet } from '../services/admin-auth.services.js'; + +export const createAdminChallengeController = 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 admin 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 verifyAdminSignatureController = async (req: Request, res: Response) => { + try { + const { address, nonce, signature } = req.body; + if (!address || !nonce || !signature) { + res.status(400).json({ error: 'address, nonce, and signature are required' }); + return; + } + if (typeof address !== 'string' || typeof nonce !== 'string' || typeof signature !== 'string') { + res.status(400).json({ error: 'address, nonce, and signature must be strings' }); + return; + } + + const result = await authenticateAdminWallet(address, nonce, signature); + + if (!result.success) { + res.status(401).json({ error: result.reason }); + return; + } + + res.status(200).json({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + admin: result.admin, + }); + } catch { + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/routes/admin/auth.routes.ts b/src/routes/admin/auth.routes.ts new file mode 100644 index 0000000..32c594d --- /dev/null +++ b/src/routes/admin/auth.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { + createAdminChallengeController, + verifyAdminSignatureController, +} from '../../controllers/admin-auth.controllers.js'; + +const router = Router(); + +router.post('/challenge', createAdminChallengeController); +router.post('/verify', verifyAdminSignatureController); + +export default router; diff --git a/src/routes/admin/index.ts b/src/routes/admin/index.ts new file mode 100644 index 0000000..5cb6069 --- /dev/null +++ b/src/routes/admin/index.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import authRoutes from './auth.routes.js'; + +const router = Router(); + +// Public: issues the wallet challenge/verify pair, no admin session yet. +router.use('/auth', authRoutes); + +// Sibling routers added by later issues (merchant.routes.ts, invoice.routes.ts, ...) +// are mounted here behind authenticateAdmin. + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 0bccaa4..7b70418 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -2,6 +2,7 @@ import merchantRoutes from './merchant.routes.js'; import authRoutes from './auth.routes.js'; import invoiceRoutes from './invoice.routes.js'; import payRoutes from './pay.routes.js'; +import adminRoutes from './admin/index.js'; import { Router } from 'express'; const router = Router(); @@ -10,5 +11,6 @@ router.use('/merchants', merchantRoutes); router.use('/auth', authRoutes); router.use('/invoices', invoiceRoutes); router.use('/pay', payRoutes); +router.use('/admin', adminRoutes); export default router; diff --git a/tests/integration/admin.routes.test.ts b/tests/integration/admin.routes.test.ts new file mode 100644 index 0000000..820ef33 --- /dev/null +++ b/tests/integration/admin.routes.test.ts @@ -0,0 +1,194 @@ +import { jest, beforeEach } from '@jest/globals'; +import { mockReset } from 'jest-mock-extended'; +import request from 'supertest'; + +const mockVerify = { returns: true }; +const mockKeypairError = { throws: false }; + +jest.unstable_mockModule('@stellar/stellar-sdk', () => ({ + Keypair: { + fromPublicKey: () => { + if (mockKeypairError.throws) { + throw new Error('invalid public key'); + } + return { + verify: () => mockVerify.returns, + }; + }, + }, + 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; +const { default: app } = await import('../../src/app.js'); + +const address = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const nonce = 'nonce-123'; +const signature = 'deadbeef'; +const mockDate = new Date('2026-06-21T12:00:00Z'); + +describe('Admin Auth Routes', () => { + beforeEach(() => { + mockReset(prismaMock); + 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/admin/auth/challenge', () => { + test('behaves like the merchant challenge endpoint for a syntactically valid address', async () => { + const generatedNonce = 'ab'.repeat(32); + const message = [ + 'Shade Authentication', + `Address: ${address}`, + `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, + nonce: generatedNonce, + message, + expiresAt, + usedAt: null, + createdAt: mockDate, + merchantId: null, + }); + + const response = await request(app).post('/api/v1/admin/auth/challenge').send({ address }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + message, + nonce: generatedNonce, + expiresAt: expiresAt.toISOString(), + }); + // No admin ownership check at this step. + expect(prismaMock.admin.findUnique).not.toHaveBeenCalled(); + }); + + test('should return 400 for an invalid Stellar address', async () => { + const response = await request(app) + .post('/api/v1/admin/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(); + }); + }); + + describe('POST /api/v1/admin/auth/verify', () => { + const mockAuthNonce = { + id: 'uuid-1', + address, + nonce, + message: `Shade Authentication\nAddress: ${address}\nNonce: ${nonce}\nTimestamp: 2026-06-21T12:00:00.000Z`, + expiresAt: new Date('2026-06-21T12:05:00.000Z'), + usedAt: null, + createdAt: mockDate, + merchantId: null, + }; + + test('returns 200 with tokens for a valid signature from an active admin', async () => { + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + prismaMock.admin.findUnique.mockResolvedValue({ + id: 'admin-uuid', + address, + active: true, + isSuperAdmin: true, + createdAt: mockDate, + updatedAt: mockDate, + }); + prismaMock.adminRefreshToken.create.mockResolvedValue({ + id: 'session-uuid', + adminId: 'admin-uuid', + token: 'refresh-uuid', + expiresAt: new Date('2026-06-28T12:00:00.000Z'), + createdAt: mockDate, + }); + + const response = await request(app) + .post('/api/v1/admin/auth/verify') + .send({ address, nonce, signature }); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + accessToken: expect.any(String), + refreshToken: expect.any(String), + admin: { + id: 'admin-uuid', + address, + isSuperAdmin: true, + }, + }); + }); + + test('returns 401 and creates no Admin row for a valid signature with no Admin row', async () => { + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + prismaMock.admin.findUnique.mockResolvedValue(null); + + const response = await request(app) + .post('/api/v1/admin/auth/verify') + .send({ address, nonce, signature }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Not an admin' }); + expect(prismaMock.admin.create).not.toHaveBeenCalled(); + }); + + test('returns 401 for a deactivated admin', async () => { + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + prismaMock.authNonce.update.mockResolvedValue(mockAuthNonce); + prismaMock.admin.findUnique.mockResolvedValue({ + id: 'admin-uuid', + address, + active: false, + isSuperAdmin: false, + createdAt: mockDate, + updatedAt: mockDate, + }); + + const response = await request(app) + .post('/api/v1/admin/auth/verify') + .send({ address, nonce, signature }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Not an admin' }); + }); + + test('returns 401 for an invalid signature', async () => { + mockVerify.returns = false; + prismaMock.authNonce.findUnique.mockResolvedValue(mockAuthNonce); + + const response = await request(app) + .post('/api/v1/admin/auth/verify') + .send({ address, nonce, signature }); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ error: 'Signature verification failed' }); + }); + + test('returns 400 when required fields are missing', async () => { + const response = await request(app).post('/api/v1/admin/auth/verify').send({}); + + expect(response.status).toBe(400); + }); + }); +}); From 92cf7c30b9aa001d91f2f0f08d78db9fd47e9edf Mon Sep 17 00:00:00 2001 From: dnnyorji Date: Fri, 21 Aug 2026 05:05:07 +0100 Subject: [PATCH 4/4] fix(auth): handle missing request body in admin verify controller req.body was destructured directly, so a POST with no body at all (no Content-Type, nothing for express.json() to parse) threw and fell through to the catch-all 500 instead of the intended 400 validation response. Defaults to {} like the sibling challenge controller already does. Also logs unexpected verify failures with path/method context, matching the challenge controller, instead of swallowing them silently. --- src/controllers/admin-auth.controllers.ts | 9 +++++++-- tests/integration/admin.routes.test.ts | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/controllers/admin-auth.controllers.ts b/src/controllers/admin-auth.controllers.ts index 8a2c4f0..4081608 100644 --- a/src/controllers/admin-auth.controllers.ts +++ b/src/controllers/admin-auth.controllers.ts @@ -26,7 +26,7 @@ export const createAdminChallengeController = async (req: Request, res: Response export const verifyAdminSignatureController = async (req: Request, res: Response) => { try { - const { address, nonce, signature } = req.body; + const { address, nonce, signature } = req.body ?? {}; if (!address || !nonce || !signature) { res.status(400).json({ error: 'address, nonce, and signature are required' }); return; @@ -48,7 +48,12 @@ export const verifyAdminSignatureController = async (req: Request, res: Response refreshToken: result.refreshToken, admin: result.admin, }); - } catch { + } catch (error) { + console.error('Failed to verify admin auth signature', { + path: req.path, + method: req.method, + error: error instanceof Error ? error.message : 'Unknown error', + }); res.status(500).json({ error: 'Internal Server Error' }); } }; diff --git a/tests/integration/admin.routes.test.ts b/tests/integration/admin.routes.test.ts index 820ef33..fd0c7c2 100644 --- a/tests/integration/admin.routes.test.ts +++ b/tests/integration/admin.routes.test.ts @@ -190,5 +190,11 @@ describe('Admin Auth Routes', () => { expect(response.status).toBe(400); }); + + test('returns 400 when the request has no body at all', async () => { + const response = await request(app).post('/api/v1/admin/auth/verify'); + + expect(response.status).toBe(400); + }); }); });