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,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;
17 changes: 15 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
59 changes: 59 additions & 0 deletions src/controllers/admin-auth.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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 (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' });
}
};
78 changes: 78 additions & 0 deletions src/middlewares/admin.middleware.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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();
};
12 changes: 12 additions & 0 deletions src/routes/admin/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
codebestia marked this conversation as resolved.

export default router;
12 changes: 12 additions & 0 deletions src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
52 changes: 52 additions & 0 deletions src/services/admin-auth.services.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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;
}
3 changes: 2 additions & 1 deletion src/types/express.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Expand Down
Loading
Loading