Skip to content

Commit 18d74db

Browse files
authored
Merge pull request #102 from bandanadivya/feature/jwt-auth
Add JWT auth: login, refresh, logout, protected endpoints
2 parents 1c3a296 + d79cb08 commit 18d74db

21 files changed

+744
-170
lines changed

backend/.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,11 @@ MAX_SNAPSHOTS_PER_PORTFOLIO=1000
185185
# SECURITY CONFIGURATION
186186
# ============================================
187187

188+
# JWT Authentication (required for token-based auth)
189+
JWT_SECRET=your-secret-key-min-32-chars-change-in-production
190+
JWT_ACCESS_EXPIRY_SEC=900
191+
JWT_REFRESH_EXPIRY_SEC=604800
192+
188193
# API rate limiting
189194
API_RATE_LIMIT_WINDOW=900000
190195
API_RATE_LIMIT_MAX_REQUESTS=100

backend/package-lock.json

Lines changed: 126 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 0 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +0,0 @@
1-
{
2-
"name": "stellar-portfolio-backend",
3-
"version": "0.1.0",
4-
"type": "module",
5-
"scripts": {
6-
"dev": "tsx watch src/index.ts",
7-
"build": "tsc && node -e \"const fs=require('fs'); const p='dist/openapi'; fs.mkdirSync(p,{recursive:true}); fs.copyFileSync('src/openapi/openapi.json', p+'/openapi.json');\"",
8-
"start": "node dist/index.js",
9-
"db:migrate": "tsx src/db/migrate.ts",
10-
"db:migrate:dry-run": "tsx src/db/migrate.ts -- --dry-run",
11-
"db:migrate:rollback": "tsx src/db/migrate.ts -- --rollback",
12-
"db:migrate:status": "tsx src/db/migrate.ts -- --status",
13-
"db:setup": "tsx src/db/migrate.ts",
14-
"db:seed:e2e": "tsx src/db/seed.e2e.ts",
15-
"test": "vitest run",
16-
"test:watch": "vitest",
17-
"openapi:export": "tsx scripts/export-openapi.ts"
18-
},
19-
"dependencies": {
20-
"@stellar/stellar-sdk": "^12.0.1",
21-
"better-sqlite3": "^12.6.2",
22-
"bullmq": "^5.69.3",
23-
"cors": "^2.8.5",
24-
"dotenv": "^16.3.1",
25-
"express": "^4.18.2",
26-
"express-rate-limit": "^7.4.1",
27-
"ioredis": "^5.9.3",
28-
"node-cron": "^3.0.3",
29-
"nodemailer": "^8.0.1",
30-
"pg": "^8.11.3",
31-
"pino": "^9.6.0",
32-
"swagger-ui-express": "^5.0.1",
33-
"ws": "^8.14.2",
34-
"zod": "^4.3.6"
35-
},
36-
"devDependencies": {
37-
"@types/better-sqlite3": "^7.6.13",
38-
"@types/cors": "^2.8.19",
39-
"@types/express": "^4.17.23",
40-
"@types/node": "^20.19.13",
41-
"@types/node-cron": "^3.0.11",
42-
"@types/nodemailer": "^7.0.10",
43-
"@types/pg": "^8.10.9",
44-
"@types/supertest": "^2.0.12",
45-
"@types/ws": "^8.18.1",
46-
"supertest": "^6.3.3",
47-
"tsx": "^4.1.4",
48-
"typescript": "^5.2.2",
49-
"vitest": "^4.0.18"
50-
}
51-
}

backend/src/api/authRoutes.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Router, Request, Response } from 'express'
2+
import {
3+
getAuthConfig,
4+
issueTokens,
5+
refreshTokens,
6+
logout
7+
} from '../services/authService.js'
8+
import { requireJwt } from '../middleware/requireJwt.js'
9+
import { ok, fail } from '../utils/apiResponse.js'
10+
import { getErrorMessage } from '../utils/helpers.js'
11+
12+
const router = Router()
13+
14+
router.post('/login', async (req: Request, res: Response) => {
15+
try {
16+
const config = getAuthConfig()
17+
if (!config.enabled) {
18+
return fail(res, 503, 'SERVICE_UNAVAILABLE', 'JWT auth not configured (set JWT_SECRET)')
19+
}
20+
const address = req.body?.address
21+
if (!address || typeof address !== 'string' || !address.trim()) {
22+
return fail(res, 400, 'VALIDATION_ERROR', 'address is required')
23+
}
24+
const trimmed = address.trim()
25+
const tokens = await issueTokens(trimmed)
26+
return ok(res, {
27+
accessToken: tokens.accessToken,
28+
refreshToken: tokens.refreshToken,
29+
expiresIn: tokens.expiresIn,
30+
refreshExpiresIn: tokens.refreshExpiresIn
31+
})
32+
} catch (error) {
33+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
34+
}
35+
})
36+
37+
router.post('/refresh', async (req: Request, res: Response) => {
38+
try {
39+
const config = getAuthConfig()
40+
if (!config.enabled) {
41+
return fail(res, 503, 'SERVICE_UNAVAILABLE', 'JWT auth not configured')
42+
}
43+
const refreshToken = req.body?.refreshToken
44+
if (!refreshToken || typeof refreshToken !== 'string') {
45+
return fail(res, 400, 'VALIDATION_ERROR', 'refreshToken is required')
46+
}
47+
const tokens = await refreshTokens(refreshToken)
48+
if (!tokens) {
49+
return fail(res, 401, 'UNAUTHORIZED', 'Invalid or expired refresh token')
50+
}
51+
return ok(res, {
52+
accessToken: tokens.accessToken,
53+
refreshToken: tokens.refreshToken,
54+
expiresIn: tokens.expiresIn,
55+
refreshExpiresIn: tokens.refreshExpiresIn
56+
})
57+
} catch (error) {
58+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
59+
}
60+
})
61+
62+
router.post('/logout', requireJwt, async (req: Request, res: Response) => {
63+
try {
64+
const refreshToken = req.body?.refreshToken
65+
const address = req.user?.address
66+
await logout(refreshToken, address)
67+
return ok(res, { message: 'Logged out' })
68+
} catch (error) {
69+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
70+
}
71+
})
72+
73+
router.post('/logout-all', async (req: Request, res: Response) => {
74+
try {
75+
const address = req.body?.address
76+
if (!address || typeof address !== 'string') {
77+
return fail(res, 400, 'VALIDATION_ERROR', 'address is required')
78+
}
79+
await logout(undefined, address.trim())
80+
return ok(res, { message: 'Logged out' })
81+
} catch (error) {
82+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
83+
}
84+
})
85+
86+
export const authRouter = router

0 commit comments

Comments
 (0)