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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Auth
JWT_SECRET=

# Blockchain
RPC_URL=

Expand Down
3 changes: 3 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@
"license": "ISC",
"packageManager": "pnpm@10.28.1",
"dependencies": {
"@stellar/stellar-sdk": "^15.1.0",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"postgres": "^3.4.9",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/morgan": "^1.9.10",
"@types/node": "^20.19.37",
"drizzle-kit": "^0.31.10",
Expand Down
9 changes: 9 additions & 0 deletions apps/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { pgTable, text, timestamp, uuid, boolean } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';

export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
Expand All @@ -18,6 +19,14 @@ export const wallets = pgTable('wallets', {
createdAt: timestamp('created_at').notNull().defaultNow(),
});

export const usersRelations = relations(users, ({ many }) => ({
wallets: many(wallets),
}));

export const walletsRelations = relations(wallets, ({ one }) => ({
user: one(users, { fields: [wallets.userId], references: [users.id] }),
}));

export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Wallet = typeof wallets.$inferSelect;
Expand Down
24 changes: 18 additions & 6 deletions apps/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@ import dotenv from 'dotenv';
import { createServer } from 'http';
import { Server } from 'socket.io';
import morgan from 'morgan';
import { db } from './db/index.js';
import { sql } from 'drizzle-orm';
import { db } from './db/index.js';
import { authRouter } from './routes/auth.js';
import { requireAuth } from './middleware/auth.js';
import { socketAuthMiddleware, type AuthSocket } from './middleware/socketAuth.js';

dotenv.config();

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: { origin: '*' }
cors: { origin: '*' },
});

app.use(cors());
Expand All @@ -28,14 +31,23 @@ app.get('/health', async (_req, res) => {
}
});

io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
app.use('/auth', authRouter);

// Protected route example
app.get('/me', requireAuth, (req, res) => {
res.json({ user: req.auth });
});

io.use(socketAuthMiddleware);

io.on('connection', (socket: AuthSocket) => {
console.log('User connected:', socket.auth?.userId, socket.id);
socket.on('disconnect', () => {
console.log('User disconnected:', socket.id);
console.log('User disconnected:', socket.auth?.userId);
});
});

const PORT = process.env.PORT || 3001;
const PORT = process.env['PORT'] ?? 3001;
httpServer.listen(PORT, () => {
console.log(`Backend server running on port ${PORT}`);
});
20 changes: 20 additions & 0 deletions apps/backend/src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import jwt from 'jsonwebtoken';

const SECRET = process.env['JWT_SECRET'];

if (!SECRET) {
throw new Error('JWT_SECRET is not set');
}

export interface JwtPayload {
userId: string;
walletAddress: string;
}

export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, SECRET, { expiresIn: '7d' });
}

export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, SECRET) as JwtPayload;
}
20 changes: 20 additions & 0 deletions apps/backend/src/lib/nonce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { randomBytes } from 'crypto';

// Nonces expire after 5 minutes
const TTL_MS = 5 * 60 * 1000;

const store = new Map<string, { nonce: string; expiresAt: number }>();

export function createNonce(walletAddress: string): string {
const nonce = randomBytes(16).toString('hex');
store.set(walletAddress, { nonce, expiresAt: Date.now() + TTL_MS });
return nonce;
}

export function consumeNonce(walletAddress: string, nonce: string): boolean {
const entry = store.get(walletAddress);
if (!entry) return false;
store.delete(walletAddress);
if (Date.now() > entry.expiresAt) return false;
return entry.nonce === nonce;
}
24 changes: 24 additions & 0 deletions apps/backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction } from 'express';
import { verifyToken, type JwtPayload } from '../lib/jwt.js';

export interface AuthRequest extends Request {
auth?: JwtPayload;
}

export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void {
const header = req.headers.authorization;

if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing or invalid Authorization header' });
return;
}

const token = header.slice(7);

try {
req.auth = verifyToken(token);
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
25 changes: 25 additions & 0 deletions apps/backend/src/middleware/socketAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Socket } from 'socket.io';
import { verifyToken, type JwtPayload } from '../lib/jwt.js';

export interface AuthSocket extends Socket {
auth?: JwtPayload;
}

export function socketAuthMiddleware(
socket: AuthSocket,
next: (err?: Error) => void,
): void {
const token = socket.handshake.auth['token'] as string | undefined;

if (!token) {
next(new Error('Authentication token required'));
return;
}

try {
socket.auth = verifyToken(token);
next();
} catch {
next(new Error('Invalid or expired token'));
}
}
84 changes: 84 additions & 0 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Router } from 'express';
import { Keypair } from '@stellar/stellar-sdk';
import { db } from '../db/index.js';
import { users, wallets } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { createNonce, consumeNonce } from '../lib/nonce.js';
import { signToken } from '../lib/jwt.js';

export const authRouter = Router();

// Step 1: client requests a challenge nonce for a wallet address
authRouter.post('/challenge', (req, res) => {
const { walletAddress } = req.body as { walletAddress?: string };

if (!walletAddress) {
res.status(400).json({ error: 'walletAddress is required' });
return;
}

const nonce = createNonce(walletAddress);
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;

res.json({ message, nonce });
});

// Step 2: client signs the message and submits the signature
authRouter.post('/verify', async (req, res) => {
const { walletAddress, signature, nonce } = req.body as {
walletAddress?: string;
signature?: string;
nonce?: string;
};

if (!walletAddress || !signature || !nonce) {
res.status(400).json({ error: 'walletAddress, signature, and nonce are required' });
return;
}

// Validate and consume nonce
const valid = consumeNonce(walletAddress, nonce);
if (!valid) {
res.status(401).json({ error: 'Invalid or expired nonce' });
return;
}

// Verify Stellar keypair signature
try {
const message = `Sign in to Clicked\nWallet: ${walletAddress}\nNonce: ${nonce}`;
const messageBytes = Buffer.from(message);
const signatureBytes = Buffer.from(signature, 'hex');
const keypair = Keypair.fromPublicKey(walletAddress);

if (!keypair.verify(messageBytes, signatureBytes)) {
res.status(401).json({ error: 'Signature verification failed' });
return;
}
} catch {
res.status(401).json({ error: 'Invalid signature or wallet address' });
return;
}

// Upsert user + wallet
let userId: string;

const existingWallet = await db.query.wallets.findFirst({
where: eq(wallets.address, walletAddress),
with: { user: true },
});

if (existingWallet) {
userId = existingWallet.userId;
} else {
const [newUser] = await db.insert(users).values({}).returning({ id: users.id });
if (!newUser) {
res.status(500).json({ error: 'Failed to create user' });
return;
}
userId = newUser.id;
await db.insert(wallets).values({ userId, address: walletAddress, isPrimary: true });
}

const token = signToken({ userId, walletAddress });
res.json({ token });
});
Loading
Loading