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
23 changes: 12 additions & 11 deletions backend/docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
@@ -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 <JWT>` 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
Expand All @@ -43,7 +44,7 @@ router.get('/streams', optionalAuthMiddleware, getStreams);
### Authorization Header

```
Authorization: Bearer <signed_transaction_xdr>
Authorization: Bearer <jwt>
```

### Example
Expand Down
4 changes: 2 additions & 2 deletions backend/src/config/swagger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
133 changes: 11 additions & 122 deletions backend/src/middleware/auth.middleware.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <signed_transaction>'
});
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 };
}
}

Expand Down
2 changes: 1 addition & 1 deletion backend/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 7 additions & 7 deletions backend/src/routes/v1/stream.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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;
4 changes: 2 additions & 2 deletions backend/src/routes/v1/user.routes.ts
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -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:
Expand Down
35 changes: 28 additions & 7 deletions backend/tests/integration/stream-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,18 @@ function buildSignedTransaction(keypair: StellarSdk.Keypair, nonce: string): str
}

async function getValidJwt(keypair: StellarSdk.Keypair): Promise<string> {
// 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', () => {
Expand Down Expand Up @@ -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);

Expand Down
7 changes: 6 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Loading
Loading