Skip to content

Commit 9d27a98

Browse files
authored
Merge pull request #597 from Ghoost-404/feat/add-auth-middleware
auth middleware
2 parents acbce28 + 380908e commit 9d27a98

9 files changed

Lines changed: 109 additions & 153 deletions

File tree

backend/docs/AUTHENTICATION.md

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
1-
# Authentication Middleware (SEP-10)
1+
# Authentication Middleware (SEP-10 + JWT)
22

3-
FlowFi API uses Stellar signed transactions for authentication, following the SEP-10 (Stellar Web Authentication) pattern.
3+
FlowFi API uses the Stellar SEP-10 challenge flow to authenticate wallets, then issues a JWT for subsequent API requests.
44

55
## Overview
66

7-
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.
7+
Authentication is performed in two phases:
88

9-
## How It Works
9+
1. Client requests a challenge from `/v1/auth/challenge`
10+
2. Client signs the challenge transaction and submits it to `/v1/auth/verify`
11+
3. Server verifies the nonce and returns a JWT
12+
4. Client uses `Authorization: Bearer <JWT>` for authenticated endpoints
1013

11-
1. **Client Side**: The client creates a Stellar transaction, signs it with their private key, and encodes it as XDR
12-
2. **Server Side**: The middleware extracts the Bearer token, decodes the XDR, and verifies the signature
13-
3. **User Attachment**: If valid, the user's public key is attached to `req.user`
14+
This simplifies clients and standardizes authentication across all protected routes.
1415

1516
## Using the Middleware
1617

1718
### Protected Routes
1819

19-
Apply `authMiddleware` to any route that requires authentication:
20+
Apply `requireAuth` to any route that requires authentication:
2021

2122
```typescript
22-
import { authMiddleware } from '../middleware/auth.middleware.js';
23+
import { requireAuth } from '../middleware/auth.js';
2324
import { Router } from 'express';
2425

2526
const router = Router();
2627

2728
// Protected endpoint
28-
router.get('/me', authMiddleware, getCurrentUser);
29+
router.get('/me', requireAuth, getCurrentUser);
2930
```
3031

3132
### Optional Authentication
@@ -43,7 +44,7 @@ router.get('/streams', optionalAuthMiddleware, getStreams);
4344
### Authorization Header
4445

4546
```
46-
Authorization: Bearer <signed_transaction_xdr>
47+
Authorization: Bearer <jwt>
4748
```
4849

4950
### Example

backend/src/config/swagger.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`,
7474
BearerAuth: {
7575
type: 'http',
7676
scheme: 'bearer',
77-
bearerFormat: 'Stellar Signed Transaction (XDR)',
78-
description: 'Stellar SEP-10 authentication. Provide a signed transaction envelope in XDR format.'
77+
bearerFormat: 'JWT',
78+
description: 'JSON Web Token issued by /v1/auth/verify after completing the SEP-10 challenge flow.'
7979
}
8080
},
8181
schemas: {

backend/src/middleware/auth.middleware.ts

Lines changed: 11 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
import type { Request, Response, NextFunction } from 'express';
2-
import * as StellarSdk from '@stellar/stellar-sdk';
3-
import type { AuthenticatedRequest, AuthUser } from '../types/auth.types.js';
4-
import logger from '../logger.js';
5-
6-
/**
7-
* Stellar network passphrase (testnet or mainnet)
8-
*/
9-
const STELLAR_NETWORK = process.env.STELLAR_NETWORK === 'mainnet'
10-
? StellarSdk.Networks.PUBLIC
11-
: StellarSdk.Networks.TESTNET;
2+
import { requireAuth, verifyJwt } from './auth.js';
3+
import type { AuthenticatedRequest } from '../types/auth.types.js';
124

135
/**
146
* Extract Bearer token from Authorization header
@@ -29,132 +21,29 @@ function extractBearerToken(req: Request): string | null {
2921
}
3022

3123
/**
32-
* Verify Stellar signed message and extract public key
33-
*
34-
* For SEP-10 authentication, the token should be a signed transaction envelope (XDR)
35-
* The transaction should contain:
36-
* - A manage_data operation with key "auth" and random value
37-
* - Source account is the authenticating user's public key
38-
* - Valid signature from the user's keypair
39-
*/
40-
function verifySignedMessage(token: string): AuthUser | null {
41-
try {
42-
// Decode the transaction envelope from base64 XDR
43-
const transaction = StellarSdk.TransactionBuilder.fromXDR(
44-
token,
45-
STELLAR_NETWORK
46-
) as StellarSdk.Transaction;
47-
48-
// Extract the source account (user's public key)
49-
const publicKey = transaction.source;
50-
51-
// Verify the transaction has valid signatures
52-
const keypair = StellarSdk.Keypair.fromPublicKey(publicKey);
53-
const transactionHash = transaction.hash();
54-
55-
// Check if transaction has at least one signature
56-
if (!transaction.signatures || transaction.signatures.length === 0) {
57-
logger.warn('Transaction has no signatures');
58-
return null;
59-
}
60-
61-
// Verify at least one signature is valid for the source account
62-
const isValid = transaction.signatures.some((signature) => {
63-
try {
64-
return keypair.verify(transactionHash, signature.signature());
65-
} catch {
66-
return false;
67-
}
68-
});
69-
70-
if (!isValid) {
71-
logger.warn('Invalid signature for public key:', publicKey);
72-
return null;
73-
}
74-
75-
// Optional: Check transaction time bounds to prevent replay attacks
76-
const now = Math.floor(Date.now() / 1000);
77-
if (transaction.timeBounds) {
78-
const minTime = parseInt(transaction.timeBounds.minTime);
79-
const maxTime = parseInt(transaction.timeBounds.maxTime);
80-
81-
if (minTime && now < minTime) {
82-
logger.warn('Transaction not yet valid');
83-
return null;
84-
}
85-
86-
if (maxTime && now > maxTime) {
87-
logger.warn('Transaction expired');
88-
return null;
89-
}
90-
}
91-
92-
return { publicKey };
93-
} catch (error) {
94-
logger.error('Error verifying signed message:', error);
95-
return null;
96-
}
97-
}
98-
99-
/**
100-
* Authentication middleware
101-
*
102-
* Extracts Bearer token from Authorization header,
103-
* verifies the Stellar signature, and attaches user to request.
24+
* Authentication middleware alias
10425
*
105-
* If authentication fails, returns 401 Unauthorized.
26+
* Uses JWT authentication via the standard challenge/verify flow.
10627
*/
107-
export const authMiddleware = (
108-
req: Request,
109-
res: Response,
110-
next: NextFunction
111-
): void => {
112-
// Extract token from Bearer header
113-
const token = extractBearerToken(req);
114-
115-
if (!token) {
116-
res.status(401).json({
117-
error: 'Unauthorized',
118-
message: 'Missing or invalid Authorization header. Expected format: Bearer <signed_transaction>'
119-
});
120-
return;
121-
}
122-
123-
// Verify signature and extract user
124-
const user = verifySignedMessage(token);
125-
126-
if (!user) {
127-
res.status(401).json({
128-
error: 'Unauthorized',
129-
message: 'Invalid or expired signature'
130-
});
131-
return;
132-
}
133-
134-
// Attach user to request
135-
(req as AuthenticatedRequest).user = user;
136-
137-
logger.debug(`Authenticated user: ${user.publicKey}`);
138-
next();
139-
};
28+
export const authMiddleware = requireAuth;
14029

14130
/**
14231
* Optional authentication middleware
14332
*
144-
* Similar to authMiddleware but doesn't fail if token is missing.
145-
* Useful for endpoints that have optional authentication.
33+
* Uses the same JWT validation as authMiddleware but does not fail when
34+
* no token is provided.
14635
*/
14736
export const optionalAuthMiddleware = (
14837
req: Request,
149-
res: Response,
38+
_res: Response,
15039
next: NextFunction
15140
): void => {
15241
const token = extractBearerToken(req);
15342

15443
if (token) {
155-
const user = verifySignedMessage(token);
156-
if (user) {
157-
(req as AuthenticatedRequest).user = user;
44+
const payload = verifyJwt(token);
45+
if (payload) {
46+
(req as AuthenticatedRequest).user = { publicKey: payload.publicKey };
15847
}
15948
}
16049

backend/src/middleware/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function signJwt(payload: object): string {
3232
return `${header}.${body}.${b64url(sig)}`;
3333
}
3434

35-
function verifyJwt(token: string): { publicKey: string } | null {
35+
export function verifyJwt(token: string): { publicKey: string } | null {
3636
try {
3737
const [header, body, sig] = token.split('.');
3838
if (!header || !body || !sig) return null;

backend/src/routes/v1/stream.routes.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from '../../controllers/stream.controller.js';
1313
import { cancelStreamHandler } from '../../controllers/stream/cancel.js';
1414
import { withdrawHandler } from './streams/withdraw.js';
15-
import { authMiddleware } from '../../middleware/auth.middleware.js';
15+
import { requireAuth } from '../../middleware/auth.js';
1616
import { streamCreationRateLimiter } from '../../middleware/stream-rate-limiter.middleware.js';
1717

1818
const router = Router();
@@ -37,7 +37,7 @@ const router = Router();
3737
* 429:
3838
* description: Too Many Requests - rate limit exceeded (10 requests per minute)
3939
*/
40-
router.post('/', authMiddleware, streamCreationRateLimiter, createStream);
40+
router.post('/', requireAuth, streamCreationRateLimiter, createStream);
4141

4242
/**
4343
* @openapi
@@ -197,7 +197,7 @@ router.get('/:streamId/claimable', getStreamClaimableAmount);
197197
* 409:
198198
* description: Conflict - stream already paused or inactive
199199
*/
200-
router.post('/:streamId/pause', authMiddleware, pauseStream);
200+
router.post('/:streamId/pause', requireAuth, pauseStream);
201201

202202
/**
203203
* @openapi
@@ -228,7 +228,7 @@ router.post('/:streamId/pause', authMiddleware, pauseStream);
228228
* 409:
229229
* description: Conflict - stream not paused or inactive
230230
*/
231-
router.post('/:streamId/resume', authMiddleware, resumeStream);
231+
router.post('/:streamId/resume', requireAuth, resumeStream);
232232

233233
/**
234234
* @openapi
@@ -259,7 +259,7 @@ router.post('/:streamId/resume', authMiddleware, resumeStream);
259259
* 409:
260260
* description: Conflict - no claimable balance available
261261
*/
262-
router.post('/:streamId/withdraw', authMiddleware, withdrawHandler as any);
262+
router.post('/:streamId/withdraw', requireAuth, withdrawHandler as any);
263263

264264
/**
265265
* @openapi
@@ -271,7 +271,7 @@ router.post('/:streamId/withdraw', authMiddleware, withdrawHandler as any);
271271
* security:
272272
* - bearerAuth: []
273273
*/
274-
router.post('/:streamId/top-up', authMiddleware, topUpStreamHandler);
275-
router.post('/:streamId/cancel', authMiddleware, cancelStreamHandler as any);
274+
router.post('/:streamId/top-up', requireAuth, topUpStreamHandler);
275+
router.post('/:streamId/cancel', requireAuth, cancelStreamHandler as any);
276276

277277
export default router;

backend/src/routes/v1/user.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Router } from 'express';
22
import { registerUser, getUser, getUserEvents, getCurrentUser } from '../../controllers/user.controller.js';
33
import { getUserStreamSummary } from '../../controllers/stream.controller.js';
4-
import { authMiddleware } from '../../middleware/auth.middleware.js';
4+
import { requireAuth } from '../../middleware/auth.js';
55

66
const router = Router();
77

@@ -84,7 +84,7 @@ const router = Router();
8484
* description: Unauthorized - invalid or missing token
8585
*/
8686
router.post('/', registerUser);
87-
router.get('/me', authMiddleware, getCurrentUser);
87+
router.get('/me', requireAuth, getCurrentUser);
8888
/**
8989
* @openapi
9090
* /v1/users/{address}/summary:

backend/tests/integration/stream-actions.test.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,18 @@ function buildSignedTransaction(keypair: StellarSdk.Keypair, nonce: string): str
6868
}
6969

7070
async function getValidJwt(keypair: StellarSdk.Keypair): Promise<string> {
71-
// The pause/resume/withdraw routes are guarded by authMiddleware, which
72-
// verifies a signed Stellar transaction envelope directly (not the JWT
73-
// issued by /v1/auth/verify). Build a fresh signed envelope each call so
74-
// the request supplies a valid bearer token.
75-
const nonce = '00'.repeat(32);
76-
return buildSignedTransaction(keypair, nonce);
71+
const challengeRes = await request(app)
72+
.post('/v1/auth/challenge')
73+
.send({ publicKey: keypair.publicKey() });
74+
75+
const nonce = challengeRes.body.nonce as string;
76+
const signedTransaction = buildSignedTransaction(keypair, nonce);
77+
78+
const verifyRes = await request(app)
79+
.post('/v1/auth/verify')
80+
.send({ publicKey: keypair.publicKey(), signedTransaction });
81+
82+
return verifyRes.body.token as string;
7783
}
7884

7985
describe('stream action routes', () => {
@@ -136,7 +142,22 @@ describe('stream action routes', () => {
136142
);
137143
});
138144

139-
it('POST /v1/streams/:streamId/resume resumes a paused sender-owned stream', async () => {
145+
it('rejects a raw signed transaction bearer token without a JWT', async () => {
146+
const sender = makeKeypair();
147+
const rawToken = buildSignedTransaction(sender, '00'.repeat(32));
148+
149+
const response = await request(app)
150+
.post('/v1/streams/7/pause')
151+
.set('Authorization', `Bearer ${rawToken}`);
152+
153+
expect(response.status).toBe(401);
154+
expect(response.body).toMatchObject({
155+
error: 'Unauthorized',
156+
message: 'Invalid or expired token',
157+
});
158+
});
159+
160+
it('POST /v1/streams/:streamId/resume resumes a paused sender-owned stream', async () =>
140161
const sender = makeKeypair();
141162
const token = await getValidJwt(sender);
142163

docker-compose.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,19 @@ services:
2222
dockerfile: Dockerfile
2323
container_name: flowfi-backend
2424
environment:
25-
NODE_ENV: production
25+
NODE_ENV: development
2626
PORT: 3001
2727
DATABASE_URL: postgresql://flowfi:flowfi_dev_password@postgres:5432/flowfi
28+
CORS_ALLOWED_ORIGINS: http://localhost:3000
29+
# Uncomment and set values for Soroban integration
30+
# SOROBAN_RPC_URL: https://rpc.testnet.stellar.org
31+
# STREAM_CONTRACT_ID: CB...YOUR_CONTRACT_ID...
2832
ports:
2933
- "3001:3001"
3034
depends_on:
3135
postgres:
3236
condition: service_healthy
37+
restart: unless-stopped
3338

3439
volumes:
3540
postgres_data:

0 commit comments

Comments
 (0)