diff --git a/SEP10_AUTHENTICATION_GUIDE.md b/SEP10_AUTHENTICATION_GUIDE.md new file mode 100644 index 0000000..2241f6c --- /dev/null +++ b/SEP10_AUTHENTICATION_GUIDE.md @@ -0,0 +1,300 @@ +# SEP-10 Stellar Authentication Implementation Guide + +## Overview + +This implementation provides complete SEP-10 (Stellar Web Authentication) support for the SubStream Protocol backend, allowing users to authenticate securely using Stellar wallets without usernames, passwords, or emails. + +## Architecture + +### Core Components + +1. **StellarAuthService** (`services/stellarAuthService.js`) + - Handles SEP-10 challenge generation and verification + - Manages cryptographic operations with Stellar SDK + - Ensures compliance with SEP-10 specification + +2. **Stellar Authentication Middleware** (`middleware/stellarAuth.js`) + - JWT token generation and validation for Stellar users + - Session management with unique session IDs + - Cookie-based authentication support + +3. **Unified Authentication Middleware** (`middleware/unifiedAuth.js`) + - Supports both Ethereum and Stellar authentication + - Automatic token type detection and routing + - Backward compatibility with existing Ethereum auth + +4. **Authentication Routes** (`routes/stellarAuth.js`) + - `/auth/challenge` - Generate SEP-10 challenge + - `/auth/verify` - Verify signed challenge and issue JWT + - Additional session management endpoints + +## API Endpoints + +### Primary Authentication Flow + +#### 1. Generate Challenge +``` +GET /auth/challenge?publicKey= +``` + +**Response:** +```json +{ + "success": true, + "challenge": "XDR_ENCODED_CHALLENGE_TRANSACTION", + "nonce": "BASE64_ENCODED_NONCE", + "expiresAt": "2024-01-01T12:05:00.000Z" +} +``` + +#### 2. Verify Challenge and Authenticate +``` +POST /auth/verify +Content-Type: application/json + +{ + "publicKey": "", + "challengeXDR": "" +} +``` + +**Response:** +```json +{ + "success": true, + "token": "", + "user": { + "publicKey": "", + "tier": "bronze", + "type": "stellar" + }, + "expiresIn": 86400 +} +``` + +### Session Management + +#### Get Session Info +``` +GET /auth/stellar/session +Authorization: Bearer +``` + +#### Logout +``` +POST /auth/stellar/logout +Authorization: Bearer +``` + +#### Switch Wallet +``` +POST /auth/stellar/switch +Authorization: Bearer +Content-Type: application/json + +{ + "newPublicKey": "", + "challengeXDR": "" +} +``` + +## SEP-10 Compliance + +### Challenge Transaction Requirements + +The implementation follows SEP-10 specification exactly: + +1. **Transaction Structure**: Single manageData operation +2. **Operation Name**: ` auth` format +3. **Source Account**: Client's Stellar public key +4. **Nonce**: Cryptographically secure random value +5. **Timebounds**: 5-minute validity window +6. **Network**: Configurable (testnet/mainnet) + +### Security Features + +- **Cryptographic Verification**: Validates wallet signature against original challenge +- **Nonce Reuse Prevention**: Each challenge can only be used once +- **Time-based Expiration**: Challenges expire after 5 minutes +- **Account Status Verification**: Checks for active, non-merged accounts +- **Session Management**: Unique session IDs with activity tracking + +## JWT Token Structure + +### Token Claims +```json +{ + "publicKey": "", + "tier": "bronze|silver|gold", + "type": "stellar", + "iat": 1234567890, + "sessionId": "" +} +``` + +### Security Features +- **Short-lived**: 24-hour expiration +- **Session Binding**: Tied to specific session ID +- **Type Identification**: Clear token type for middleware routing +- **Revocation Support**: Sessions can be invalidated + +## Integration Examples + +### Frontend Integration (JavaScript) + +```javascript +// 1. Generate challenge +const response = await fetch('/auth/challenge?publicKey=GABC...'); +const { challenge } = await response.json(); + +// 2. Sign challenge with wallet (e.g., Freighter) +const signedChallenge = await freighter.signTransaction(challenge); + +// 3. Verify and get token +const authResponse = await fetch('/auth/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publicKey: 'GABC...', + challengeXDR: signedChallenge + }) +}); + +const { token } = await authResponse.json(); +localStorage.setItem('authToken', token); +``` + +### Protected API Access + +```javascript +// Access protected endpoint +const response = await fetch('/content/my-videos', { + headers: { + 'Authorization': `Bearer ${token}` + } +}); +``` + +## Configuration + +### Environment Variables + +```bash +# Stellar Network Configuration +STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +STELLAR_HORIZON_URL="https://horizon-testnet.stellar.org" + +# Authentication +JWT_SECRET="your-secure-secret-key" +DOMAIN="substream-protocol.com" + +# Test Credentials (for integration testing) +STELLAR_TEST_PUBLIC_KEY="GABC..." +STELLAR_TEST_SECRET="SABC..." +``` + +## Testing + +### Unit Tests +- Challenge generation and validation +- JWT token creation and verification +- Middleware authentication logic +- SEP-10 specification compliance + +### Integration Tests +- Full authentication flow with testnet accounts +- Protected endpoint access +- Session management +- Wallet switching functionality + +### Running Tests +```bash +# Run all authentication tests +npm test -- stellarAuth.test.js + +# Run SEP-10 compliance tests +npm test -- sep10Compliance.test.js + +# Run integration tests (requires testnet credentials) +STELLAR_TEST_PUBLIC_KEY=GABC... STELLAR_TEST_SECRET=SABC... npm test +``` + +## Security Considerations + +### Production Deployment + +1. **HTTPS Required**: All authentication endpoints must use HTTPS +2. **Secure JWT Secret**: Use strong, randomly generated secrets +3. **Rate Limiting**: Implement rate limiting on auth endpoints +4. **CORS Configuration**: Properly configure cross-origin requests +5. **Session Storage**: Use Redis for production session management + +### Best Practices + +1. **Token Refresh**: Implement token refresh mechanism +2. **Session Cleanup**: Regular cleanup of expired sessions +3. **Audit Logging**: Log authentication events for security +4. **Error Handling**: Generic error messages to prevent information leakage +5. **Input Validation**: Strict validation of all inputs + +## Migration Guide + +### From Ethereum Authentication + +1. **Update Frontend**: Replace SIWE flow with SEP-10 flow +2. **Update API Calls**: Use new `/auth/challenge` and `/auth/verify` endpoints +3. **Token Handling**: Existing middleware supports both token types +4. **User Identification**: Use `getUserId()` helper for unified access + +### Backward Compatibility + +- Existing Ethereum tokens continue to work +- Unified middleware automatically detects token type +- No breaking changes to existing protected routes +- Gradual migration possible + +## Troubleshooting + +### Common Issues + +1. **Invalid Challenge XDR** + - Check network passphrase configuration + - Verify challenge hasn't expired + - Ensure proper XDR encoding + +2. **Signature Verification Failed** + - Verify wallet signed the correct transaction + - Check public key format and validity + - Ensure transaction wasn't modified + +3. **JWT Token Issues** + - Verify JWT secret consistency + - Check token expiration + - Validate session ID exists + +4. **Account Status Errors** + - Ensure account exists on network + - Check account isn't merged + - Verify network connectivity + +### Debug Mode + +Enable debug logging: +```bash +DEBUG=stellar:* npm run dev +``` + +## Support + +For issues related to: +- **SEP-10 Specification**: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0010.md +- **Stellar SDK**: https://github.com/stellar/js-stellar-sdk +- **Implementation Issues**: Create GitHub issue with detailed logs + +## Future Enhancements + +1. **Multi-sig Support**: Support for multi-signature accounts +2. **Hardware Wallets**: Enhanced support for Ledger/Trezor +3. **Delegation Support**: Stellar account delegation features +4. **Biometric Auth**: Integration with mobile biometric authentication +5. **Social Recovery**: Account recovery mechanisms diff --git a/middleware/unifiedAuth.js b/middleware/unifiedAuth.js new file mode 100644 index 0000000..358eee4 --- /dev/null +++ b/middleware/unifiedAuth.js @@ -0,0 +1,138 @@ +const jwt = require('jsonwebtoken'); +const { authenticateToken: ethAuthenticateToken, requireTier } = require('./auth'); +const { authenticateStellarToken } = require('./stellarAuth'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +/** + * Unified authentication middleware that supports both Ethereum and Stellar tokens + * Checks for token type and validates accordingly + */ +const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ + success: false, + error: 'Access token required' + }); + } + + try { + // Decode token to check type without verification + const decoded = jwt.decode(token); + + if (!decoded || !decoded.type) { + // Default to Ethereum authentication for backward compatibility + return ethAuthenticateToken(req, res, next); + } + + // Route to appropriate authentication based on token type + if (decoded.type === 'stellar') { + return authenticateStellarToken(req, res, next); + } else if (decoded.type === 'ethereum' || !decoded.type) { + return ethAuthenticateToken(req, res, next); + } else { + return res.status(403).json({ + success: false, + error: 'Invalid token type' + }); + } + } catch (error) { + return res.status(403).json({ + success: false, + error: 'Invalid token format' + }); + } +}; + +/** + * Middleware to require specific authentication type + */ +const requireAuthType = (type) => { + return (req, res, next) => { + if (!req.user || !req.user.type) { + return res.status(403).json({ + success: false, + error: 'Authentication type not found' + }); + } + + if (req.user.type !== type) { + return res.status(403).json({ + success: false, + error: `${type} authentication required` + }); + } + + next(); + }; +}; + +/** + * Middleware to require Stellar authentication specifically + */ +const requireStellarAuth = requireAuthType('stellar'); + +/** + * Middleware to require Ethereum authentication specifically + */ +const requireEthereumAuth = requireAuthType('ethereum'); + +/** + * Get user identifier (address or publicKey) regardless of auth type + */ +const getUserId = (user) => { + if (!user) return null; + return user.address || user.publicKey; +}; + +/** + * Check if user has required tier (works for both auth types) + */ +const hasRequiredTier = (user, requiredTier) => { + if (!user || !user.tier) return false; + + const tierHierarchy = { bronze: 1, silver: 2, gold: 3 }; + const userTierLevel = tierHierarchy[user.tier] || 0; + const requiredTierLevel = tierHierarchy[requiredTier] || 0; + + return userTierLevel >= requiredTierLevel; +}; + +/** + * Enhanced tier-based access middleware that works with both auth types + */ +const requireTierUnified = (requiredTier) => { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } + + if (!hasRequiredTier(req.user, requiredTier)) { + return res.status(403).json({ + success: false, + error: `${requiredTier} tier required` + }); + } + + next(); + }; +}; + +module.exports = { + authenticateToken, + requireAuthType, + requireStellarAuth, + requireEthereumAuth, + getUserId, + hasRequiredTier, + requireTierUnified, + // Export original middleware for specific use cases + ethAuthenticateToken, + authenticateStellarToken +}; diff --git a/routes/analytics.js b/routes/analytics.js index e16727d..335f02d 100644 --- a/routes/analytics.js +++ b/routes/analytics.js @@ -1,7 +1,7 @@ const express = require('express'); const router = express.Router(); const analyticsService = require('../services/analyticsService'); -const { authenticateToken } = require('../middleware/auth'); +const { authenticateToken, getUserId } = require('../middleware/unifiedAuth'); // Get global platform statistics (cached for performance) router.get('/global', async (req, res) => { diff --git a/routes/comments.js b/routes/comments.js index cac7a81..5aa2473 100644 --- a/routes/comments.js +++ b/routes/comments.js @@ -3,7 +3,7 @@ const { AppDatabase } = require('../src/db/appDatabase'); const { loadConfig } = require('../src/config'); const { SorobanSubscriptionVerifier } = require('../src/services/sorobanSubscriptionVerifier'); const { CommentService } = require('../services/commentService'); -const { authenticateToken } = require('../middleware/auth'); +const { authenticateToken, getUserId } = require('../middleware/unifiedAuth'); // Initialize services const config = loadConfig(); diff --git a/routes/content.js b/routes/content.js index d3f1272..5994c87 100644 --- a/routes/content.js +++ b/routes/content.js @@ -23,6 +23,19 @@ const express = require('express'); const router = express.Router(); +const contentService = require('../services/contentService'); +const { authenticateToken, requireTierUnified, getUserId } = require('../middleware/unifiedAuth'); + +// Get content by ID with tier-based filtering +router.get('/:contentId', authenticateToken, (req, res) => { + try { + const { contentId } = req.params; + const content = contentService.getContent(contentId, getUserId(req.user)); + + res.json({ + success: true, + content + }); const { requireTier } = require('../middleware/tierAuth'); const tierService = require('../services/tierService'); @@ -43,11 +56,17 @@ const contentService = require('../services/contentService'); */ router.get('/', async (req, res) => { try { - const userTier = req.user?.tier || 'guest'; - const allContent = await contentService.getAllContent(); - const filtered = tierService.filterContentList(allContent, userTier); + const filters = { + creator: req.query.creator, + tier: req.query.tier, + tags: req.query.tags ? req.query.tags.split(',') : undefined, + userAddress: getUserId(req.user), + search: req.query.search + }; - return res.json({ + const contentList = contentService.listContent(getUserId(req.user), filters); + + res.json({ success: true, tier: userTier, total: filtered.length, @@ -61,27 +80,66 @@ router.get('/', async (req, res) => { } }); -/** - * GET /content/tier-status - * - * Returns the current user's tier rank, access map, next tier, and - * an upgrade message. Intended for tier management / upgrade UI. - * - * Example response: - * { - * current: 'bronze', - * rank: 1, - * canAccess: { guest: true, bronze: true, silver: false, gold: false }, - * nextTier: 'silver', - * upgradeMessage: 'Upgrade to Silver to unlock more content.' - * } - */ -router.get('/tier-status', (req, res) => { - const userTier = req.user?.tier || 'guest'; - return res.json({ success: true, ...tierService.tierStatus(userTier) }); +// Create new content (creator only) +router.post('/', authenticateToken, requireTierUnified('bronze'), (req, res) => { + try { + const { + title, + description, + requiredTier = 'bronze', + thumbnail, + duration, + price, + tags + } = req.body; + + if (!title || !description) { + return res.status(400).json({ + success: false, + error: 'Title and description are required' + }); + } + + const content = contentService.addContent({ + title, + description, + requiredTier, + thumbnail, + duration: parseFloat(duration), + price, + tags: tags || [] + }, getUserId(req.user)); + + res.status(201).json({ + success: true, + content + }); + + } catch (error) { + console.error('Create content error:', error); + res.status(500).json({ + success: false, + error: error.message || 'Failed to create content' + }); + } }); -// ── Tier-gated list endpoints ─────────────────────────────────────────────── +// Update content (creator only) +router.put('/:contentId', authenticateToken, (req, res) => { + try { + const { contentId } = req.params; + const updates = req.body; + + // Don't allow updating creator or creation date + delete updates.creator; + delete updates.createdAt; + + const updatedContent = contentService.updateContent(contentId, updates, getUserId(req.user)); + + res.json({ + success: true, + content: updatedContent + }); /** * GET /content/tier/bronze @@ -90,11 +148,21 @@ router.get('/tier-status', (req, res) => { */ router.get('/tier/bronze', requireTier('bronze'), async (req, res) => { try { - const items = await contentService.getContentByTier('bronze'); - return res.json({ success: true, tier: 'bronze', total: items.length, items }); - } catch (err) { - console.error('[content] GET /tier/bronze error:', err); - return res.status(500).json({ success: false, error: 'Failed to fetch bronze content' }); + const { contentId } = req.params; + + contentService.deleteContent(contentId, getUserId(req.user)); + + res.json({ + success: true, + message: 'Content deleted successfully' + }); + + } catch (error) { + console.error('Delete content error:', error); + res.status(403).json({ + success: false, + error: error.message || 'Failed to delete content' + }); } }); @@ -104,11 +172,22 @@ router.get('/tier/bronze', requireTier('bronze'), async (req, res) => { */ router.get('/tier/silver', requireTier('silver'), async (req, res) => { try { - const items = await contentService.getContentByTier('silver'); - return res.json({ success: true, tier: 'silver', total: items.length, items }); - } catch (err) { - console.error('[content] GET /tier/silver error:', err); - return res.status(500).json({ success: false, error: 'Failed to fetch silver content' }); + const { contentId } = req.params; + const canAccess = contentService.canAccessContent(contentId, getUserId(req.user)); + + res.json({ + success: true, + contentId, + canAccess, + userTier: contentService.getUserTier(getUserId(req.user)) + }); + + } catch (error) { + console.error('Check access error:', error); + res.status(500).json({ + success: false, + error: 'Failed to check access' + }); } }); @@ -118,11 +197,21 @@ router.get('/tier/silver', requireTier('silver'), async (req, res) => { */ router.get('/tier/gold', requireTier('gold'), async (req, res) => { try { - const items = await contentService.getContentByTier('gold'); - return res.json({ success: true, tier: 'gold', total: items.length, items }); - } catch (err) { - console.error('[content] GET /tier/gold error:', err); - return res.status(500).json({ success: false, error: 'Failed to fetch gold content' }); + const { creatorAddress } = req.params; + const stats = contentService.getCreatorStats(creatorAddress, getUserId(req.user)); + + res.json({ + success: true, + creatorAddress, + stats + }); + + } catch (error) { + console.error('Get creator stats error:', error); + res.status(500).json({ + success: false, + error: 'Failed to get creator statistics' + }); } }); @@ -143,15 +232,57 @@ router.get('/tier/gold', requireTier('gold'), async (req, res) => { */ router.get('/:id', async (req, res) => { try { - const userTier = req.user?.tier || 'guest'; - const content = await contentService.getContentById(req.params.id); + const suggestions = contentService.getUpgradeSuggestions(getUserId(req.user)); + + res.json({ + success: true, + suggestions + }); + + } catch (error) { + console.error('Get upgrade suggestions error:', error); + res.status(500).json({ + success: false, + error: 'Failed to get upgrade suggestions' + }); + } +}); if (!content) { return res.status(404).json({ success: false, error: 'Content not found' }); } - if (!tierService.canAccess(userTier, content.tier)) { - return res.status(403).json({ + const filters = { + ...req.query, + requiredTier: tierName, + userAddress: getUserId(req.user) + }; + + const contentList = contentService.listContent(getUserId(req.user), filters); + + res.json({ + success: true, + tier: tierName, + content: contentList, + count: contentList.length + }); + + } catch (error) { + console.error('Get content by tier error:', error); + res.status(500).json({ + success: false, + error: 'Failed to get content by tier' + }); + } +}); + +// Search content with tier awareness +router.post('/search', authenticateToken, (req, res) => { + try { + const { query, filters = {} } = req.body; + + if (!query) { + return res.status(400).json({ success: false, error: 'Insufficient subscription tier', required: content.tier, @@ -160,10 +291,61 @@ router.get('/:id', async (req, res) => { }); } - return res.json({ success: true, ...content, locked: false }); - } catch (err) { - console.error('[content] GET /:id error:', err); - return res.status(500).json({ success: false, error: 'Failed to fetch content' }); + const searchFilters = { + ...filters, + search: query, + userAddress: getUserId(req.user) + }; + + const results = contentService.listContent(getUserId(req.user), searchFilters); + + res.json({ + success: true, + query, + filters, + userAddress: getUserId(req.user), + results, + count: results.length + }); + + } catch (error) { + console.error('Search content error:', error); + res.status(500).json({ + success: false, + error: 'Failed to search content' + }); + } +}); + +// Get user's accessible content summary +router.get('/user/summary', authenticateToken, (req, res) => { + try { + const userTier = contentService.getUserTier(getUserId(req.user)); + const allContent = contentService.listContent(getUserId(req.user)); + + const summary = { + userTier, + totalContent: allContent.length, + accessibleContent: allContent.filter(c => !c.censored).length, + restrictedContent: allContent.filter(c => c.censored).length, + contentByTier: { + bronze: allContent.filter(c => c.requiredTier === 'bronze').length, + silver: allContent.filter(c => c.requiredTier === 'silver').length, + gold: allContent.filter(c => c.requiredTier === 'gold').length + } + }; + + res.json({ + success: true, + summary + }); + + } catch (error) { + console.error('Get user summary error:', error); + res.status(500).json({ + success: false, + error: 'Failed to get user summary' + }); } }); diff --git a/routes/posts.js b/routes/posts.js index 19190b5..c39f9b5 100644 --- a/routes/posts.js +++ b/routes/posts.js @@ -1,12 +1,12 @@ const express = require('express'); const router = express.Router(); const postService = require('../services/postService'); -const { authenticateToken, requireTier } = require('../middleware/auth'); +const { authenticateToken, requireTierUnified, getUserId } = require('../middleware/unifiedAuth'); // Get all posts router.get('/', authenticateToken, (req, res) => { try { - const posts = postService.getAllPosts(req.user.address); + const posts = postService.getAllPosts(getUserId(req.user)); res.json({ success: true, diff --git a/routes/stellarAuth.js b/routes/stellarAuth.js index 9050473..ad3697a 100644 --- a/routes/stellarAuth.js +++ b/routes/stellarAuth.js @@ -16,9 +16,9 @@ const stellarService = new StellarAuthService(); /** * Generate SEP-10 challenge for Stellar authentication - * GET /auth/stellar/challenge?publicKey=... + * GET /auth/challenge?publicKey=... */ -router.get('/stellar/challenge', async (req, res) => { +router.get('/challenge', async (req, res) => { try { const { publicKey } = req.query; @@ -58,9 +58,9 @@ router.get('/stellar/challenge', async (req, res) => { /** * Verify SEP-10 challenge and authenticate - * POST /auth/stellar/login + * POST /auth/verify */ -router.post('/stellar/login', async (req, res) => { +router.post('/verify', async (req, res) => { try { const { publicKey, challengeXDR } = req.body; diff --git a/routes/storage.js b/routes/storage.js index 9d75fac..d67e9f6 100644 --- a/routes/storage.js +++ b/routes/storage.js @@ -2,7 +2,7 @@ const express = require('express'); const router = express.Router(); const multer = require('multer'); const storageService = require('../services/storageService'); -const { authenticateToken } = require('../middleware/auth'); +const { authenticateToken } = require('../middleware/unifiedAuth'); // Configure multer for file uploads const upload = multer({ diff --git a/sep10Compliance.test.js b/sep10Compliance.test.js new file mode 100644 index 0000000..6eb2bbd --- /dev/null +++ b/sep10Compliance.test.js @@ -0,0 +1,338 @@ +const request = require("supertest"); +const app = require("./index"); +const StellarSdk = require("@stellar/stellar-sdk"); + +describe("SEP-10 Compliance Tests", () => { + let testKeypair; + let testPublicKey; + let authToken; + let challengeXDR; + + beforeAll(() => { + // Generate test keypair for testing + testKeypair = StellarSdk.Keypair.random(); + testPublicKey = testKeypair.publicKey(); + }); + + describe("Challenge Generation (/auth/challenge)", () => { + it("should generate a SEP-10 compliant challenge", async () => { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.challenge).toBeDefined(); + expect(response.body.nonce).toBeDefined(); + expect(response.body.expiresAt).toBeDefined(); + + // Verify challenge is valid XDR + const transaction = StellarSdk.TransactionBuilder.fromXDR( + response.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + + // Verify transaction structure + expect(transaction.operations.length).toBe(1); + expect(transaction.operations[0].type).toBe("manageData"); + expect(transaction.operations[0].source).toBe(testPublicKey); + + // Verify operation name matches domain auth pattern + const expectedName = `${process.env.DOMAIN || "substream-protocol.com"} auth`; + expect(transaction.operations[0].name).toBe(expectedName); + + // Verify timebounds are present and reasonable + expect(transaction.timebounds).toBeDefined(); + expect(transaction.timebounds.minTime).toBeGreaterThan(0); + expect(transaction.timebounds.maxTime).toBeGreaterThan(transaction.timebounds.minTime); + + challengeXDR = response.body.challenge; + }); + + it("should reject request without public key", async () => { + const response = await request(app) + .get("/auth/challenge") + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBe("Stellar public key required"); + }); + + it("should reject invalid public key format", async () => { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: "invalid-key" }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBe("Invalid Stellar public key format"); + }); + + it("should generate unique nonces for each request", async () => { + const response1 = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const response2 = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + expect(response1.body.nonce).not.toBe(response2.body.nonce); + }); + }); + + describe("Challenge Verification (/auth/verify)", () => { + it("should reject verification without required fields", async () => { + const response = await request(app) + .post("/auth/verify") + .send({}) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBe( + "Missing required fields: publicKey, challengeXDR" + ); + }); + + it("should reject invalid challenge XDR", async () => { + const response = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: "invalid-xdr", + }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + it("should reject challenge with wrong public key", async () => { + const differentKeypair = StellarSdk.Keypair.random(); + + const response = await request(app) + .post("/auth/verify") + .send({ + publicKey: differentKeypair.publicKey(), + challengeXDR: challengeXDR, + }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); + + describe("JWT Token Security", () => { + it("should issue JWT with correct claims after successful authentication", async () => { + // This test would require a funded testnet account for full integration + // For now, we test the token structure expectations + expect(true).toBe(true); // Placeholder + }); + + it("should reject requests with invalid JWT", async () => { + const response = await request(app) + .get("/auth/stellar/session") + .set("Authorization", "Bearer invalid-token") + .expect(403); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBe("Invalid or expired token"); + }); + + it("should reject requests without JWT", async () => { + const response = await request(app) + .get("/auth/stellar/session") + .expect(401); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBe("Access token required"); + }); + }); + + describe("SEP-10 Specification Compliance", () => { + it("should follow SEP-10 challenge transaction format", async () => { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + response.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + + // SEP-10 requirements: + // 1. Transaction must have exactly one operation + expect(transaction.operations.length).toBe(1); + + // 2. Operation must be manageData + expect(transaction.operations[0].type).toBe("manageData"); + + // 3. Operation source account must be the client account + expect(transaction.operations[0].source).toBe(testPublicKey); + + // 4. Operation name must be auth + const expectedName = `${process.env.DOMAIN || "substream-protocol.com"} auth`; + expect(transaction.operations[0].name).toBe(expectedName); + + // 5. Operation value must be a nonce + expect(transaction.operations[0].value).toBeDefined(); + expect(transaction.operations[0].value.length).toBeGreaterThan(0); + + // 6. Transaction must have timebounds + expect(transaction.timebounds).toBeDefined(); + expect(transaction.timebounds.minTime).toBeDefined(); + expect(transaction.timebounds.maxTime).toBeDefined(); + + // 7. Timebounds should be reasonable (5 minutes is standard) + const timeDiff = transaction.timebounds.maxTime - transaction.timebounds.minTime; + expect(timeDiff).toBeLessThanOrEqual(300); // 5 minutes + expect(timeDiff).toBeGreaterThan(0); + }); + + it("should use correct network passphrase", async () => { + const expectedPassphrase = process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015"; + + // This is verified implicitly by the fromXDR parsing succeeding + // If the network passphrase was wrong, parsing would fail + expect(expectedPassphrase).toBeDefined(); + }); + }); + + describe("Security Requirements", () => { + it("should have short-lived challenges (5 minutes)", async () => { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const expiresAt = new Date(response.body.expiresAt); + const now = new Date(); + const timeToExpiry = expiresAt - now; + + // Should expire in approximately 5 minutes (with some tolerance) + expect(timeToExpiry).toBeGreaterThan(4 * 60 * 1000); // More than 4 minutes + expect(timeToExpiry).toBeLessThan(6 * 60 * 1000); // Less than 6 minutes + }); + + it("should use secure nonce generation", async () => { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + // Nonce should be base64-encoded and reasonable length + expect(response.body.nonce).toBeDefined(); + expect(response.body.nonce.length).toBeGreaterThan(10); + + // Should be different each time + const response2 = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + expect(response.body.nonce).not.toBe(response2.body.nonce); + }); + }); +}); + +// Full Integration Test (requires testnet account) +describe("SEP-10 Full Integration Test", () => { + let fundedKeypair; + let fundedPublicKey; + + beforeAll(async () => { + // These tests require a funded testnet account + if ( + !process.env.STELLAR_TEST_PUBLIC_KEY || + !process.env.STELLAR_TEST_SECRET + ) { + console.log("Skipping integration tests - no test credentials provided"); + return; + } + + fundedPublicKey = process.env.STELLAR_TEST_PUBLIC_KEY; + fundedKeypair = StellarSdk.Keypair.fromSecret( + process.env.STELLAR_TEST_SECRET, + ); + }); + + it("should complete full SEP-10 authentication flow", async () => { + if (!fundedKeypair) { + console.log("Skipping integration test"); + return; + } + + // 1. Generate challenge + const challengeResponse = await request(app) + .get("/auth/challenge") + .query({ publicKey: fundedPublicKey }) + .expect(200); + + expect(challengeResponse.body.success).toBe(true); + + // 2. Parse and sign the challenge + const transaction = StellarSdk.TransactionBuilder.fromXDR( + challengeResponse.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015", + ); + + transaction.sign(fundedKeypair); + const signedChallengeXDR = transaction.toXDR(); + + // 3. Verify and authenticate + const verifyResponse = await request(app) + .post("/auth/verify") + .send({ + publicKey: fundedPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(200); + + expect(verifyResponse.body.success).toBe(true); + expect(verifyResponse.body.token).toBeDefined(); + expect(verifyResponse.body.user.publicKey).toBe( + fundedPublicKey.toLowerCase(), + ); + expect(verifyResponse.body.user.type).toBe('stellar'); + + const token = verifyResponse.body.token; + + // 4. Test protected endpoint access + const sessionResponse = await request(app) + .get("/auth/stellar/session") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(sessionResponse.body.success).toBe(true); + expect(sessionResponse.body.session.publicKey).toBe( + fundedPublicKey.toLowerCase(), + ); + + // 5. Verify JWT contains required claims + const jwt = require('jsonwebtoken'); + const decoded = jwt.decode(token); + + expect(decoded.publicKey).toBe(fundedPublicKey.toLowerCase()); + expect(decoded.type).toBe('stellar'); + expect(decoded.iat).toBeDefined(); + expect(decoded.sessionId).toBeDefined(); + + // 6. Test logout + const logoutResponse = await request(app) + .post("/auth/stellar/logout") + .set("Authorization", `Bearer ${token}`) + .expect(200); + + expect(logoutResponse.body.success).toBe(true); + + // 7. Verify token is invalidated after logout + const sessionAfterLogout = await request(app) + .get("/auth/stellar/session") + .set("Authorization", `Bearer ${token}`) + .expect(403); + + expect(sessionAfterLogout.body.success).toBe(false); + }, 30000); // Longer timeout for network operations +}); diff --git a/sep10Integration.test.js b/sep10Integration.test.js new file mode 100644 index 0000000..e50624e --- /dev/null +++ b/sep10Integration.test.js @@ -0,0 +1,485 @@ +const request = require("supertest"); +const app = require("./index"); +const StellarSdk = require("@stellar/stellar-sdk"); + +describe("SEP-10 Complete Integration Tests", () => { + let testKeypair; + let testPublicKey; + let authToken; + let challengeXDR; + + beforeAll(() => { + // Generate test keypair for testing + testKeypair = StellarSdk.Keypair.random(); + testPublicKey = testKeypair.publicKey(); + }); + + describe("Acceptance Criteria 1: Secure Authentication Without Passwords", () => { + it("should allow users to authenticate using only Stellar public key", async () => { + // Step 1: Generate challenge with just public key + const challengeResponse = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + expect(challengeResponse.body.success).toBe(true); + expect(challengeResponse.body.challenge).toBeDefined(); + expect(challengeResponse.body.nonce).toBeDefined(); + expect(challengeResponse.body.expiresAt).toBeDefined(); + + // Verify no username/password/email was required + expect(challengeResponse.body).not.toHaveProperty('username'); + expect(challengeResponse.body).not.toHaveProperty('password'); + expect(challengeResponse.body).not.toHaveProperty('email'); + + challengeXDR = challengeResponse.body.challenge; + }); + + it("should issue JWT token after wallet signature verification", async () => { + // This test simulates the wallet signing process + // In a real scenario, the wallet would sign the challenge + + // For testing purposes, we'll create a valid signature + const transaction = StellarSdk.TransactionBuilder.fromXDR( + challengeXDR, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + + // Sign with the test keypair (simulating wallet signature) + transaction.sign(testKeypair); + const signedChallengeXDR = transaction.toXDR(); + + // Verify and get token + const verifyResponse = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(200); + + expect(verifyResponse.body.success).toBe(true); + expect(verifyResponse.body.token).toBeDefined(); + expect(verifyResponse.body.user.publicKey).toBe(testPublicKey.toLowerCase()); + expect(verifyResponse.body.user.type).toBe('stellar'); + expect(verifyResponse.body.user.tier).toBeDefined(); + + authToken = verifyResponse.body.token; + + // Verify JWT contains public key as subject claim + const jwt = require('jsonwebtoken'); + const decoded = jwt.decode(authToken); + expect(decoded.publicKey).toBe(testPublicKey.toLowerCase()); + expect(decoded.type).toBe('stellar'); + }); + }); + + describe("Acceptance Criteria 2: SEP-10 Specification Compliance", () => { + it("should generate SEP-10 compliant challenge transactions", async () => { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + response.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + + // SEP-10 Requirements Verification + expect(transaction.operations.length).toBe(1); + expect(transaction.operations[0].type).toBe("manageData"); + expect(transaction.operations[0].source).toBe(testPublicKey); + + // Operation name must follow auth format + const expectedName = `${process.env.DOMAIN || "substream-protocol.com"} auth`; + expect(transaction.operations[0].name).toBe(expectedName); + + // Must have timebounds + expect(transaction.timebounds).toBeDefined(); + expect(transaction.timebounds.minTime).toBeGreaterThan(0); + expect(transaction.timebounds.maxTime).toBeGreaterThan(transaction.timebounds.minTime); + + // Timebounds should be reasonable (5 minutes standard) + const timeDiff = transaction.timebounds.maxTime - transaction.timebounds.minTime; + expect(timeDiff).toBeLessThanOrEqual(300); // 5 minutes + }); + + it("should verify wallet signature against original challenge", async () => { + // Generate fresh challenge + const challengeResponse = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + challengeResponse.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + + // Sign with correct keypair + transaction.sign(testKeypair); + const signedChallengeXDR = transaction.toXDR(); + + const verifyResponse = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(200); + + expect(verifyResponse.body.success).toBe(true); + + // Test with wrong signature (different keypair) + const wrongKeypair = StellarSdk.Keypair.random(); + const wrongTransaction = StellarSdk.TransactionBuilder.fromXDR( + challengeResponse.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + wrongTransaction.sign(wrongKeypair); + const wrongSignedXDR = wrongTransaction.toXDR(); + + const wrongVerifyResponse = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: wrongSignedXDR, + }) + .expect(400); + + expect(wrongVerifyResponse.body.success).toBe(false); + expect(wrongVerifyResponse.body.error).toContain('Invalid signature'); + }); + + it("should prevent nonce reuse and enforce expiration", async () => { + // Generate challenge + const challengeResponse = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + challengeResponse.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + transaction.sign(testKeypair); + const signedChallengeXDR = transaction.toXDR(); + + // First verification should succeed + const firstVerify = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(200); + + expect(firstVerify.body.success).toBe(true); + + // Second verification with same challenge should fail + const secondVerify = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(400); + + expect(secondVerify.body.success).toBe(false); + expect(secondVerify.body.error).toContain('already used'); + }); + }); + + describe("Acceptance Criteria 3: Protected Route Security", () => { + it("should deny access to protected routes without authentication", async () => { + // Test various protected routes + const protectedRoutes = [ + '/content', + '/storage/health', + '/analytics/view-event', + '/posts' + ]; + + for (const route of protectedRoutes) { + const response = await request(app) + .get(route) + .expect(401); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Access token required'); + } + }); + + it("should allow access to protected routes with valid Stellar JWT", async () => { + // Ensure we have a valid token + if (!authToken) { + const challengeResponse = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + challengeResponse.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + transaction.sign(testKeypair); + const signedChallengeXDR = transaction.toXDR(); + + const verifyResponse = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(200); + + authToken = verifyResponse.body.token; + } + + // Test access to protected routes + const response = await request(app) + .get('/content') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should reject invalid JWT tokens", async () => { + const invalidTokens = [ + 'invalid.token.format', + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature', + 'completely-invalid-token' + ]; + + for (const token of invalidTokens) { + const response = await request(app) + .get('/content') + .set('Authorization', `Bearer ${token}`) + .expect(403); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Invalid or expired token'); + } + }); + + it("should reject tokens for wrong authentication type", async () => { + // Create a fake Ethereum-style token + const jwt = require('jsonwebtoken'); + const fakeEthToken = jwt.sign( + { + address: testPublicKey.toLowerCase(), + tier: 'bronze', + type: 'ethereum' // Wrong type for Stellar auth + }, + process.env.JWT_SECRET || 'test-secret', + { expiresIn: '1h' } + ); + + // Try to access Stellar-specific endpoint + const response = await request(app) + .get('/auth/stellar/session') + .set('Authorization', `Bearer ${fakeEthToken}`) + .expect(403); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Invalid token type'); + }); + }); + + describe("Additional Security Features", () => { + it("should handle session management correctly", async () => { + if (!authToken) { + // Create a new token for this test + const challengeResponse = await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const transaction = StellarSdk.TransactionBuilder.fromXDR( + challengeResponse.body.challenge, + process.env.STELLAR_NETWORK_PASSPHRASE || "Test SDF Network ; September 2015" + ); + transaction.sign(testKeypair); + const signedChallengeXDR = transaction.toXDR(); + + const verifyResponse = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: signedChallengeXDR, + }) + .expect(200); + + authToken = verifyResponse.body.token; + } + + // Get session info + const sessionResponse = await request(app) + .get('/auth/stellar/session') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(sessionResponse.body.success).toBe(true); + expect(sessionResponse.body.session.publicKey).toBe(testPublicKey.toLowerCase()); + + // Logout + const logoutResponse = await request(app) + .post('/auth/stellar/logout') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(logoutResponse.body.success).toBe(true); + + // Token should be invalid after logout + const sessionAfterLogout = await request(app) + .get('/auth/stellar/session') + .set('Authorization', `Bearer ${authToken}`) + .expect(403); + + expect(sessionAfterLogout.body.success).toBe(false); + }); + + it("should enforce rate limiting on authentication endpoints", async () => { + // Test rate limiting by making multiple rapid requests + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push( + request(app) + .get("/auth/challenge") + .query({ publicKey: StellarSdk.Keypair.random().publicKey() }) + ); + } + + const responses = await Promise.all(promises); + + // At least some requests should succeed + const successCount = responses.filter(r => r.status === 200).length; + expect(successCount).toBeGreaterThan(0); + + // Some might be rate limited (429) depending on configuration + const rateLimitedCount = responses.filter(r => r.status === 429).length; + // This is optional behavior, so we don't enforce it strictly + }); + }); + + describe("Error Handling and Edge Cases", () => { + it("should handle invalid public keys gracefully", async () => { + const invalidKeys = [ + 'invalid-key', + 'G123', // Too short + 'G' + 'A'.repeat(56), // Invalid format + '', + null, + undefined + ]; + + for (const key of invalidKeys) { + const response = await request(app) + .get("/auth/challenge") + .query({ publicKey: key }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Stellar public key required'); + } + }); + + it("should handle malformed XDR gracefully", async () => { + const malformedXDRs = [ + 'invalid-xdr', + 'AAAAAA==', + '', + null, + undefined + ]; + + for (const xdr of malformedXDRs) { + const response = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey, + challengeXDR: xdr + }) + .expect(400); + + expect(response.body.success).toBe(false); + } + }); + + it("should handle missing required fields", async () => { + // Missing publicKey + const response1 = await request(app) + .post("/auth/verify") + .send({ + challengeXDR: 'some-xdr' + }) + .expect(400); + + expect(response1.body.error).toContain('Missing required fields'); + + // Missing challengeXDR + const response2 = await request(app) + .post("/auth/verify") + .send({ + publicKey: testPublicKey + }) + .expect(400); + + expect(response2.body.error).toContain('Missing required fields'); + + // Missing both + const response3 = await request(app) + .post("/auth/verify") + .send({}) + .expect(400); + + expect(response3.body.error).toContain('Missing required fields'); + }); + }); + + describe("Performance and Scalability", () => { + it("should handle concurrent authentication requests", async () => { + const concurrentRequests = 20; + const promises = []; + + for (let i = 0; i < concurrentRequests; i++) { + const keypair = StellarSdk.Keypair.random(); + promises.push( + request(app) + .get("/auth/challenge") + .query({ publicKey: keypair.publicKey() }) + ); + } + + const responses = await Promise.all(promises); + + // All requests should succeed + const successCount = responses.filter(r => r.status === 200).length; + expect(successCount).toBe(concurrentRequests); + + // All responses should have valid challenge structure + responses.forEach(response => { + expect(response.body.success).toBe(true); + expect(response.body.challenge).toBeDefined(); + expect(response.body.nonce).toBeDefined(); + expect(response.body.expiresAt).toBeDefined(); + }); + }); + + it("should have reasonable response times", async () => { + const startTime = Date.now(); + + await request(app) + .get("/auth/challenge") + .query({ publicKey: testPublicKey }) + .expect(200); + + const responseTime = Date.now() - startTime; + + // Should respond within reasonable time (less than 1 second) + expect(responseTime).toBeLessThan(1000); + }); + }); +});