From 380908ede02d8eceb67e3589c437f3cf7c156171 Mon Sep 17 00:00:00 2001 From: Marvell69 Date: Sat, 30 May 2026 20:22:57 +0100 Subject: [PATCH] auth middleware --- backend/docs/AUTHENTICATION.md | 23 +-- backend/src/config/swagger.ts | 4 +- backend/src/middleware/auth.middleware.ts | 133 ++---------------- backend/src/middleware/auth.ts | 2 +- backend/src/routes/v1/stream.routes.ts | 14 +- backend/src/routes/v1/user.routes.ts | 4 +- .../tests/integration/stream-actions.test.ts | 35 ++++- docker-compose.yml | 7 +- render.yaml | 40 ++++++ 9 files changed, 109 insertions(+), 153 deletions(-) diff --git a/backend/docs/AUTHENTICATION.md b/backend/docs/AUTHENTICATION.md index 0f9e72d6..984cf617 100644 --- a/backend/docs/AUTHENTICATION.md +++ b/backend/docs/AUTHENTICATION.md @@ -1,31 +1,32 @@ -# Authentication Middleware (SEP-10) +# Authentication Middleware (SEP-10 + JWT) -FlowFi API uses Stellar signed transactions for authentication, following the SEP-10 (Stellar Web Authentication) pattern. +FlowFi API uses the Stellar SEP-10 challenge flow to authenticate wallets, then issues a JWT for subsequent API requests. ## Overview -The authentication middleware verifies that requests come from legitimate Stellar wallet owners by validating signed transactions. This provides secure, wallet-based authentication without traditional username/password schemes. +Authentication is performed in two phases: -## How It Works +1. Client requests a challenge from `/v1/auth/challenge` +2. Client signs the challenge transaction and submits it to `/v1/auth/verify` +3. Server verifies the nonce and returns a JWT +4. Client uses `Authorization: Bearer ` for authenticated endpoints -1. **Client Side**: The client creates a Stellar transaction, signs it with their private key, and encodes it as XDR -2. **Server Side**: The middleware extracts the Bearer token, decodes the XDR, and verifies the signature -3. **User Attachment**: If valid, the user's public key is attached to `req.user` +This simplifies clients and standardizes authentication across all protected routes. ## Using the Middleware ### Protected Routes -Apply `authMiddleware` to any route that requires authentication: +Apply `requireAuth` to any route that requires authentication: ```typescript -import { authMiddleware } from '../middleware/auth.middleware.js'; +import { requireAuth } from '../middleware/auth.js'; import { Router } from 'express'; const router = Router(); // Protected endpoint -router.get('/me', authMiddleware, getCurrentUser); +router.get('/me', requireAuth, getCurrentUser); ``` ### Optional Authentication @@ -43,7 +44,7 @@ router.get('/streams', optionalAuthMiddleware, getStreams); ### Authorization Header ``` -Authorization: Bearer +Authorization: Bearer ``` ### Example diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 6cfd197b..6c699c07 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -74,8 +74,8 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, BearerAuth: { type: 'http', scheme: 'bearer', - bearerFormat: 'Stellar Signed Transaction (XDR)', - description: 'Stellar SEP-10 authentication. Provide a signed transaction envelope in XDR format.' + bearerFormat: 'JWT', + description: 'JSON Web Token issued by /v1/auth/verify after completing the SEP-10 challenge flow.' } }, schemas: { diff --git a/backend/src/middleware/auth.middleware.ts b/backend/src/middleware/auth.middleware.ts index f9a14264..f677ee11 100644 --- a/backend/src/middleware/auth.middleware.ts +++ b/backend/src/middleware/auth.middleware.ts @@ -1,14 +1,6 @@ import type { Request, Response, NextFunction } from 'express'; -import * as StellarSdk from '@stellar/stellar-sdk'; -import type { AuthenticatedRequest, AuthUser } from '../types/auth.types.js'; -import logger from '../logger.js'; - -/** - * Stellar network passphrase (testnet or mainnet) - */ -const STELLAR_NETWORK = process.env.STELLAR_NETWORK === 'mainnet' - ? StellarSdk.Networks.PUBLIC - : StellarSdk.Networks.TESTNET; +import { requireAuth, verifyJwt } from './auth.js'; +import type { AuthenticatedRequest } from '../types/auth.types.js'; /** * Extract Bearer token from Authorization header @@ -29,132 +21,29 @@ function extractBearerToken(req: Request): string | null { } /** - * Verify Stellar signed message and extract public key - * - * For SEP-10 authentication, the token should be a signed transaction envelope (XDR) - * The transaction should contain: - * - A manage_data operation with key "auth" and random value - * - Source account is the authenticating user's public key - * - Valid signature from the user's keypair - */ -function verifySignedMessage(token: string): AuthUser | null { - try { - // Decode the transaction envelope from base64 XDR - const transaction = StellarSdk.TransactionBuilder.fromXDR( - token, - STELLAR_NETWORK - ) as StellarSdk.Transaction; - - // Extract the source account (user's public key) - const publicKey = transaction.source; - - // Verify the transaction has valid signatures - const keypair = StellarSdk.Keypair.fromPublicKey(publicKey); - const transactionHash = transaction.hash(); - - // Check if transaction has at least one signature - if (!transaction.signatures || transaction.signatures.length === 0) { - logger.warn('Transaction has no signatures'); - return null; - } - - // Verify at least one signature is valid for the source account - const isValid = transaction.signatures.some((signature) => { - try { - return keypair.verify(transactionHash, signature.signature()); - } catch { - return false; - } - }); - - if (!isValid) { - logger.warn('Invalid signature for public key:', publicKey); - return null; - } - - // Optional: Check transaction time bounds to prevent replay attacks - const now = Math.floor(Date.now() / 1000); - if (transaction.timeBounds) { - const minTime = parseInt(transaction.timeBounds.minTime); - const maxTime = parseInt(transaction.timeBounds.maxTime); - - if (minTime && now < minTime) { - logger.warn('Transaction not yet valid'); - return null; - } - - if (maxTime && now > maxTime) { - logger.warn('Transaction expired'); - return null; - } - } - - return { publicKey }; - } catch (error) { - logger.error('Error verifying signed message:', error); - return null; - } -} - -/** - * Authentication middleware - * - * Extracts Bearer token from Authorization header, - * verifies the Stellar signature, and attaches user to request. + * Authentication middleware alias * - * If authentication fails, returns 401 Unauthorized. + * Uses JWT authentication via the standard challenge/verify flow. */ -export const authMiddleware = ( - req: Request, - res: Response, - next: NextFunction -): void => { - // Extract token from Bearer header - const token = extractBearerToken(req); - - if (!token) { - res.status(401).json({ - error: 'Unauthorized', - message: 'Missing or invalid Authorization header. Expected format: Bearer ' - }); - return; - } - - // Verify signature and extract user - const user = verifySignedMessage(token); - - if (!user) { - res.status(401).json({ - error: 'Unauthorized', - message: 'Invalid or expired signature' - }); - return; - } - - // Attach user to request - (req as AuthenticatedRequest).user = user; - - logger.debug(`Authenticated user: ${user.publicKey}`); - next(); -}; +export const authMiddleware = requireAuth; /** * Optional authentication middleware * - * Similar to authMiddleware but doesn't fail if token is missing. - * Useful for endpoints that have optional authentication. + * Uses the same JWT validation as authMiddleware but does not fail when + * no token is provided. */ export const optionalAuthMiddleware = ( req: Request, - res: Response, + _res: Response, next: NextFunction ): void => { const token = extractBearerToken(req); if (token) { - const user = verifySignedMessage(token); - if (user) { - (req as AuthenticatedRequest).user = user; + const payload = verifyJwt(token); + if (payload) { + (req as AuthenticatedRequest).user = { publicKey: payload.publicKey }; } } diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 043739e1..73847358 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -32,7 +32,7 @@ function signJwt(payload: object): string { return `${header}.${body}.${b64url(sig)}`; } -function verifyJwt(token: string): { publicKey: string } | null { +export function verifyJwt(token: string): { publicKey: string } | null { try { const [header, body, sig] = token.split('.'); if (!header || !body || !sig) return null; diff --git a/backend/src/routes/v1/stream.routes.ts b/backend/src/routes/v1/stream.routes.ts index d5453535..55255560 100644 --- a/backend/src/routes/v1/stream.routes.ts +++ b/backend/src/routes/v1/stream.routes.ts @@ -12,7 +12,7 @@ import { } from '../../controllers/stream.controller.js'; import { cancelStreamHandler } from '../../controllers/stream/cancel.js'; import { withdrawHandler } from './streams/withdraw.js'; -import { authMiddleware } from '../../middleware/auth.middleware.js'; +import { requireAuth } from '../../middleware/auth.js'; import { streamCreationRateLimiter } from '../../middleware/stream-rate-limiter.middleware.js'; const router = Router(); @@ -37,7 +37,7 @@ const router = Router(); * 429: * description: Too Many Requests - rate limit exceeded (10 requests per minute) */ -router.post('/', authMiddleware, streamCreationRateLimiter, createStream); +router.post('/', requireAuth, streamCreationRateLimiter, createStream); /** * @openapi @@ -197,7 +197,7 @@ router.get('/:streamId/claimable', getStreamClaimableAmount); * 409: * description: Conflict - stream already paused or inactive */ -router.post('/:streamId/pause', authMiddleware, pauseStream); +router.post('/:streamId/pause', requireAuth, pauseStream); /** * @openapi @@ -228,7 +228,7 @@ router.post('/:streamId/pause', authMiddleware, pauseStream); * 409: * description: Conflict - stream not paused or inactive */ -router.post('/:streamId/resume', authMiddleware, resumeStream); +router.post('/:streamId/resume', requireAuth, resumeStream); /** * @openapi @@ -259,7 +259,7 @@ router.post('/:streamId/resume', authMiddleware, resumeStream); * 409: * description: Conflict - no claimable balance available */ -router.post('/:streamId/withdraw', authMiddleware, withdrawHandler as any); +router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any); /** * @openapi @@ -271,7 +271,7 @@ router.post('/:streamId/withdraw', authMiddleware, withdrawHandler as any); * security: * - bearerAuth: [] */ -router.post('/:streamId/top-up', authMiddleware, topUpStreamHandler); -router.post('/:streamId/cancel', authMiddleware, cancelStreamHandler as any); +router.post('/:streamId/top-up', requireAuth, topUpStreamHandler); +router.post('/:streamId/cancel', requireAuth, cancelStreamHandler as any); export default router; diff --git a/backend/src/routes/v1/user.routes.ts b/backend/src/routes/v1/user.routes.ts index 78b6031e..9d5528b9 100644 --- a/backend/src/routes/v1/user.routes.ts +++ b/backend/src/routes/v1/user.routes.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; import { registerUser, getUser, getUserEvents, getCurrentUser } from '../../controllers/user.controller.js'; import { getUserStreamSummary } from '../../controllers/stream.controller.js'; -import { authMiddleware } from '../../middleware/auth.middleware.js'; +import { requireAuth } from '../../middleware/auth.js'; const router = Router(); @@ -84,7 +84,7 @@ const router = Router(); * description: Unauthorized - invalid or missing token */ router.post('/', registerUser); -router.get('/me', authMiddleware, getCurrentUser); +router.get('/me', requireAuth, getCurrentUser); /** * @openapi * /v1/users/{address}/summary: diff --git a/backend/tests/integration/stream-actions.test.ts b/backend/tests/integration/stream-actions.test.ts index f7b56936..93d8593c 100644 --- a/backend/tests/integration/stream-actions.test.ts +++ b/backend/tests/integration/stream-actions.test.ts @@ -68,12 +68,18 @@ function buildSignedTransaction(keypair: StellarSdk.Keypair, nonce: string): str } async function getValidJwt(keypair: StellarSdk.Keypair): Promise { - // The pause/resume/withdraw routes are guarded by authMiddleware, which - // verifies a signed Stellar transaction envelope directly (not the JWT - // issued by /v1/auth/verify). Build a fresh signed envelope each call so - // the request supplies a valid bearer token. - const nonce = '00'.repeat(32); - return buildSignedTransaction(keypair, nonce); + const challengeRes = await request(app) + .post('/v1/auth/challenge') + .send({ publicKey: keypair.publicKey() }); + + const nonce = challengeRes.body.nonce as string; + const signedTransaction = buildSignedTransaction(keypair, nonce); + + const verifyRes = await request(app) + .post('/v1/auth/verify') + .send({ publicKey: keypair.publicKey(), signedTransaction }); + + return verifyRes.body.token as string; } describe('stream action routes', () => { @@ -136,7 +142,22 @@ describe('stream action routes', () => { ); }); - it('POST /v1/streams/:streamId/resume resumes a paused sender-owned stream', async () => { + it('rejects a raw signed transaction bearer token without a JWT', async () => { + const sender = makeKeypair(); + const rawToken = buildSignedTransaction(sender, '00'.repeat(32)); + + const response = await request(app) + .post('/v1/streams/7/pause') + .set('Authorization', `Bearer ${rawToken}`); + + expect(response.status).toBe(401); + expect(response.body).toMatchObject({ + error: 'Unauthorized', + message: 'Invalid or expired token', + }); + }); + + it('POST /v1/streams/:streamId/resume resumes a paused sender-owned stream', async () => const sender = makeKeypair(); const token = await getValidJwt(sender); diff --git a/docker-compose.yml b/docker-compose.yml index 2eb1e671..970a427c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,14 +22,19 @@ services: dockerfile: Dockerfile container_name: flowfi-backend environment: - NODE_ENV: production + NODE_ENV: development PORT: 3001 DATABASE_URL: postgresql://flowfi:flowfi_dev_password@postgres:5432/flowfi + CORS_ALLOWED_ORIGINS: http://localhost:3000 + # Uncomment and set values for Soroban integration + # SOROBAN_RPC_URL: https://rpc.testnet.stellar.org + # STREAM_CONTRACT_ID: CB...YOUR_CONTRACT_ID... ports: - "3001:3001" depends_on: postgres: condition: service_healthy + restart: unless-stopped volumes: postgres_data: diff --git a/render.yaml b/render.yaml index 5f255228..c75757fe 100644 --- a/render.yaml +++ b/render.yaml @@ -23,6 +23,46 @@ services: value: "false" - key: LOG_LEVEL value: info + + # ─── Required Infrastructure ────────────────────────────────────────── + - key: CORS_ALLOWED_ORIGINS + value: "https://app.flowfi.xyz,https://flowfi.xyz" # Replace with your deployed frontend origin(s) + + - key: JWT_SECRET + sync: false # Sensitive: Generate with `openssl rand -hex 32` and set in dashboard + + # ─── Stellar / Soroban (Required for core features) ─────────────────── + - key: STELLAR_NETWORK + value: testnet # testnet or mainnet + + - key: SOROBAN_RPC_URL + value: https://soroban-testnet.stellar.org + + - key: STREAM_CONTRACT_ID + value: "" # REQUIRED: Set to your deployed contract ID (starts with C...) + + - key: KEEPER_SECRET_KEY + sync: false # Sensitive: Stellar secret key (S...) for signing on-chain top-ups + + - key: SOROBAN_SECRET_KEY + sync: false # Sensitive: Stellar secret key (S...) for signing on-chain cancel actions + + # ─── Admin & Security (Required for admin endpoints) ────────────────── + - key: ADMIN_PUBLIC_KEY + value: "" # REQUIRED: Stellar public key (G...) of the authorized admin + + - key: ADMIN_SECRET + sync: false # Sensitive: Bearer token for admin metrics/actions + + # ─── Optional Configuration ─────────────────────────────────────────── + - key: REDIS_URL + value: "" # Optional: Enable horizontal scaling for SSE via Redis + + - key: INDEXER_POLL_INTERVAL_MS + value: "5000" + + - key: INDEXER_START_LEDGER + value: "0" # 0 = start from latest ledger databases: - name: flowfi-postgres