@progalaxyelabs/stonescriptphp-files 1.0.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@progalaxyelabs/stonescriptphp-files",
3
- "version": "1.0.1",
3
+ "version": "2.0.0",
4
4
  "description": "Zero-config Express file server with Azure Blob Storage and JWT authentication",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -18,7 +18,8 @@
18
18
  "multer": "^1.4.5-lts.1",
19
19
  "jsonwebtoken": "^9.0.2",
20
20
  "cors": "^2.8.5",
21
- "uuid": "^11.0.5"
21
+ "uuid": "^11.0.5",
22
+ "express-rate-limit": "^7.4.0"
22
23
  },
23
24
  "keywords": [
24
25
  "azure",
package/src/auth.js CHANGED
@@ -41,9 +41,13 @@ export function createAuthMiddleware(publicKey) {
41
41
  });
42
42
  }
43
43
 
44
+ // Extract tenant_id from token
45
+ const tenantId = decoded.tenant_id || decoded.tid || decoded.tenant_uuid || null;
46
+
44
47
  // Attach user info to request
45
48
  req.user = {
46
49
  id: userId,
50
+ tenantId,
47
51
  email: decoded.email,
48
52
  roles: decoded.roles || [],
49
53
  ...decoded
@@ -75,6 +79,85 @@ export function createAuthMiddleware(publicKey) {
75
79
  };
76
80
  }
77
81
 
82
+ /**
83
+ * JWKS-based JWT validation middleware factory
84
+ * Creates a middleware that validates JWT tokens using JWKS (JSON Web Key Sets)
85
+ *
86
+ * @param {JwksClient} jwksClient - JWKS client instance for key retrieval
87
+ * @returns {Function} Express middleware function
88
+ */
89
+ export function createJwksAuthMiddleware(jwksClient) {
90
+ if (!jwksClient) {
91
+ throw new Error('JWKS client is required');
92
+ }
93
+
94
+ return async function authenticateJwks(req, res, next) {
95
+ const authHeader = req.headers.authorization;
96
+
97
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
98
+ return res.status(401).json({
99
+ error: 'Unauthorized',
100
+ message: 'Missing or invalid authorization header'
101
+ });
102
+ }
103
+
104
+ const token = authHeader.substring(7); // Remove 'Bearer ' prefix
105
+
106
+ try {
107
+ // Get signing key from JWKS
108
+ const { key, algorithm } = await jwksClient.getSigningKey(token);
109
+
110
+ // Verify token with the retrieved key
111
+ const decoded = jwt.verify(token, key, { algorithms: [algorithm] });
112
+
113
+ // Extract user_id from token
114
+ const userId = decoded.sub || decoded.user_id || decoded.userId || decoded.id;
115
+
116
+ if (!userId) {
117
+ return res.status(401).json({
118
+ error: 'Unauthorized',
119
+ message: 'Token missing user identifier'
120
+ });
121
+ }
122
+
123
+ // Extract tenant_id from token
124
+ const tenantId = decoded.tenant_id || decoded.tid || decoded.tenant_uuid || null;
125
+
126
+ // Attach user info to request
127
+ req.user = {
128
+ id: userId,
129
+ tenantId,
130
+ email: decoded.email,
131
+ roles: decoded.roles || [],
132
+ ...decoded
133
+ };
134
+
135
+ next();
136
+ } catch (error) {
137
+ console.error('JWKS JWT validation error:', error.message);
138
+
139
+ if (error.name === 'TokenExpiredError') {
140
+ return res.status(401).json({
141
+ error: 'Unauthorized',
142
+ message: 'Token expired'
143
+ });
144
+ }
145
+
146
+ if (error.name === 'JsonWebTokenError') {
147
+ return res.status(401).json({
148
+ error: 'Unauthorized',
149
+ message: 'Invalid token'
150
+ });
151
+ }
152
+
153
+ return res.status(401).json({
154
+ error: 'Unauthorized',
155
+ message: 'Token validation failed'
156
+ });
157
+ }
158
+ };
159
+ }
160
+
78
161
  /**
79
162
  * Optional: Role-based authorization middleware factory
80
163
  * Use after authenticateJWT to check for specific roles
@@ -42,21 +42,23 @@ export class AzureStorageClient {
42
42
 
43
43
  /**
44
44
  * Upload file to Azure Blob Storage
45
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
45
46
  * @param {string} userId - User ID (for namespacing)
46
47
  * @param {Buffer} fileBuffer - File content
47
48
  * @param {string} originalFilename - Original filename
48
49
  * @param {string} contentType - MIME type
49
50
  * @returns {Object} File metadata
50
51
  */
51
- async uploadFile(userId, fileBuffer, originalFilename, contentType) {
52
+ async uploadFile(tenantId, userId, fileBuffer, originalFilename, contentType) {
52
53
  if (!this.containerClient) {
53
54
  throw new Error('Azure Storage not initialized');
54
55
  }
55
56
 
56
- // Generate unique blob name: {user_id}/{uuid}.{ext}
57
+ // Generate unique blob name with tenant prefix: {tenant_id}/{user_id}/{uuid}.{ext}
57
58
  const fileId = uuidv4();
58
59
  const extension = originalFilename.split('.').pop();
59
- const blobName = `${userId}/${fileId}.${extension}`;
60
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
61
+ const blobName = `${prefix}${fileId}.${extension}`;
60
62
 
61
63
  const blockBlobClient = this.containerClient.getBlockBlobClient(blobName);
62
64
 
@@ -66,6 +68,7 @@ export class AzureStorageClient {
66
68
  blobContentType: contentType
67
69
  },
68
70
  metadata: {
71
+ tenant_id: tenantId || '',
69
72
  user_id: userId,
70
73
  original_filename: originalFilename,
71
74
  content_type: contentType,
@@ -77,6 +80,7 @@ export class AzureStorageClient {
77
80
  return {
78
81
  fileId,
79
82
  blobName,
83
+ tenantId,
80
84
  userId,
81
85
  originalFilename,
82
86
  contentType,
@@ -88,17 +92,19 @@ export class AzureStorageClient {
88
92
  /**
89
93
  * Download file from Azure Blob Storage
90
94
  * @param {string} fileId - File ID (UUID)
95
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
91
96
  * @param {string} userId - User ID (for authorization)
92
97
  * @returns {Object} { stream, metadata }
93
98
  */
94
- async downloadFile(fileId, userId) {
99
+ async downloadFile(fileId, tenantId, userId) {
95
100
  if (!this.containerClient) {
96
101
  throw new Error('Azure Storage not initialized');
97
102
  }
98
103
 
99
- // List blobs with file_id metadata to find the blob
104
+ // List blobs with prefix to find the blob
105
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
100
106
  const blobs = this.containerClient.listBlobsFlat({
101
- prefix: `${userId}/`,
107
+ prefix,
102
108
  includeMetadata: true
103
109
  });
104
110
 
@@ -130,17 +136,19 @@ export class AzureStorageClient {
130
136
 
131
137
  /**
132
138
  * List user's files
139
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
133
140
  * @param {string} userId - User ID
134
141
  * @returns {Array} List of file metadata
135
142
  */
136
- async listFiles(userId) {
143
+ async listFiles(tenantId, userId) {
137
144
  if (!this.containerClient) {
138
145
  throw new Error('Azure Storage not initialized');
139
146
  }
140
147
 
141
148
  const files = [];
149
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
142
150
  const blobs = this.containerClient.listBlobsFlat({
143
- prefix: `${userId}/`,
151
+ prefix,
144
152
  includeMetadata: true
145
153
  });
146
154
 
@@ -162,16 +170,18 @@ export class AzureStorageClient {
162
170
  /**
163
171
  * Delete file from Azure Blob Storage
164
172
  * @param {string} fileId - File ID (UUID)
173
+ * @param {string} tenantId - Tenant ID (for multi-tenant namespacing)
165
174
  * @param {string} userId - User ID (for authorization)
166
175
  */
167
- async deleteFile(fileId, userId) {
176
+ async deleteFile(fileId, tenantId, userId) {
168
177
  if (!this.containerClient) {
169
178
  throw new Error('Azure Storage not initialized');
170
179
  }
171
180
 
172
181
  // Find blob by file_id
182
+ const prefix = tenantId ? `${tenantId}/${userId}/` : `${userId}/`;
173
183
  const blobs = this.containerClient.listBlobsFlat({
174
- prefix: `${userId}/`,
184
+ prefix,
175
185
  includeMetadata: true
176
186
  });
177
187
 
package/src/index.js CHANGED
@@ -1,13 +1,45 @@
1
1
  import express from 'express';
2
2
  import cors from 'cors';
3
3
  import { AzureStorageClient } from './azure-storage.js';
4
- import { createAuthMiddleware } from './auth.js';
4
+ import { createAuthMiddleware, createJwksAuthMiddleware } from './auth.js';
5
+ import { JwksClient } from './jwks-client.js';
6
+ import { createRateLimiters } from './rate-limit.js';
5
7
  import { createUploadRouter } from './routes/upload.js';
6
8
  import { createDownloadRouter } from './routes/download.js';
7
9
  import { createListRouter } from './routes/list.js';
8
10
  import { createDeleteRouter } from './routes/delete.js';
9
11
  import { createHealthRouter } from './routes/health.js';
10
12
 
13
+ /**
14
+ * Resolve the authentication middleware based on configuration priority.
15
+ * Priority: authServers > jwksUrl > jwtPublicKey
16
+ *
17
+ * @param {Object} config - Configuration options
18
+ * @returns {Function} Express middleware function for authentication
19
+ */
20
+ function resolveAuthMiddleware(config) {
21
+ const authServersJson = config.authServers || (process.env.AUTH_SERVERS ? JSON.parse(process.env.AUTH_SERVERS) : null);
22
+ const jwksUrl = config.jwksUrl || process.env.JWKS_URL;
23
+ const jwtPublicKey = config.jwtPublicKey || process.env.JWT_PUBLIC_KEY;
24
+ const cacheTtl = config.jwksCacheTtl || parseInt(process.env.JWKS_CACHE_TTL) || 3600;
25
+
26
+ if (authServersJson) {
27
+ const jwksClient = new JwksClient(authServersJson.map(s => ({ ...s, cacheTtl })));
28
+ return createJwksAuthMiddleware(jwksClient);
29
+ }
30
+
31
+ if (jwksUrl) {
32
+ const jwksClient = new JwksClient([{ issuer: '*', jwksUrl, cacheTtl }]);
33
+ return createJwksAuthMiddleware(jwksClient);
34
+ }
35
+
36
+ if (jwtPublicKey) {
37
+ return createAuthMiddleware(jwtPublicKey);
38
+ }
39
+
40
+ throw new Error('No authentication method configured. Set AUTH_SERVERS, JWKS_URL, or JWT_PUBLIC_KEY.');
41
+ }
42
+
11
43
  /**
12
44
  * Create a configured files server
13
45
  * @param {Object} config - Configuration options
@@ -15,23 +47,33 @@ import { createHealthRouter } from './routes/health.js';
15
47
  * @param {string} config.containerName - Azure container name (default: process.env.AZURE_CONTAINER_NAME || 'platform-files')
16
48
  * @param {string} config.azureConnectionString - Azure storage connection string (default: process.env.AZURE_STORAGE_CONNECTION_STRING)
17
49
  * @param {string} config.jwtPublicKey - JWT public key for auth (default: process.env.JWT_PUBLIC_KEY)
50
+ * @param {Array} config.authServers - Array of auth server configs [{issuer, jwksUrl, cacheTtl?}]
51
+ * @param {string} config.jwksUrl - Single JWKS URL for key retrieval
52
+ * @param {number} config.jwksCacheTtl - JWKS cache TTL in seconds (default: 3600)
53
+ * @param {boolean} config.tenantScoped - Enable tenant-scoped file isolation (default: true)
18
54
  * @param {number} config.maxFileSize - Max file size in bytes (default: 100MB)
19
55
  * @param {string|string[]} config.corsOrigins - CORS allowed origins (default: '*')
56
+ * @param {number} config.rateLimitWindowMs - Rate limit window in ms (default: 60000)
57
+ * @param {number} config.rateLimitUpload - Max uploads per window (default: 10)
58
+ * @param {number} config.rateLimitDownload - Max downloads per window (default: 60)
20
59
  * @returns {Object} Server object with app, storage, and listen() method
21
60
  */
22
61
  export function createFilesServer(config = {}) {
23
62
  const port = config.port || process.env.PORT || 3000;
24
63
  const containerName = config.containerName || process.env.AZURE_CONTAINER_NAME || 'platform-files';
25
64
  const connectionString = config.azureConnectionString || process.env.AZURE_STORAGE_CONNECTION_STRING;
26
- const jwtPublicKey = config.jwtPublicKey || process.env.JWT_PUBLIC_KEY;
27
65
  const maxFileSize = config.maxFileSize || 100 * 1024 * 1024;
28
66
  const corsOrigins = config.corsOrigins || '*';
67
+ const tenantScoped = config.tenantScoped !== undefined ? config.tenantScoped : (process.env.TENANT_SCOPED !== 'false');
29
68
 
30
69
  // Create storage client
31
70
  const storage = new AzureStorageClient(connectionString, containerName);
32
71
 
33
- // Create auth middleware
34
- const authenticate = createAuthMiddleware(jwtPublicKey);
72
+ // Resolve auth middleware based on config priority
73
+ const authenticate = resolveAuthMiddleware(config);
74
+
75
+ // Create rate limiters
76
+ const { uploadLimiter, downloadLimiter } = createRateLimiters(config);
35
77
 
36
78
  // Create Express app
37
79
  const app = express();
@@ -49,12 +91,12 @@ export function createFilesServer(config = {}) {
49
91
  next();
50
92
  });
51
93
 
52
- // Routes
94
+ // Routes with rate limiters
53
95
  app.use(createHealthRouter());
54
- app.use(authenticate, createUploadRouter(storage, maxFileSize));
55
- app.use(authenticate, createDownloadRouter(storage));
56
- app.use(authenticate, createListRouter(storage));
57
- app.use(authenticate, createDeleteRouter(storage));
96
+ app.use(authenticate, uploadLimiter, createUploadRouter(storage, maxFileSize));
97
+ app.use(authenticate, downloadLimiter, createDownloadRouter(storage));
98
+ app.use(authenticate, downloadLimiter, createListRouter(storage));
99
+ app.use(authenticate, downloadLimiter, createDeleteRouter(storage));
58
100
 
59
101
  // 404 handler
60
102
  app.use((req, res) => {
@@ -106,7 +148,9 @@ export function createFilesServer(config = {}) {
106
148
 
107
149
  // Named exports for advanced/composable usage
108
150
  export { AzureStorageClient } from './azure-storage.js';
109
- export { createAuthMiddleware } from './auth.js';
151
+ export { createAuthMiddleware, createJwksAuthMiddleware } from './auth.js';
152
+ export { JwksClient } from './jwks-client.js';
153
+ export { createRateLimiters } from './rate-limit.js';
110
154
  export { createUploadRouter } from './routes/upload.js';
111
155
  export { createDownloadRouter } from './routes/download.js';
112
156
  export { createListRouter } from './routes/list.js';
@@ -0,0 +1,59 @@
1
+ import crypto from 'crypto';
2
+ import jwt from 'jsonwebtoken';
3
+
4
+ export class JwksClient {
5
+ constructor(authServers) {
6
+ // authServers: [{issuer, jwksUrl, cacheTtl?}]
7
+ this.authServers = authServers;
8
+ this.cache = new Map(); // key: issuerKey, value: {jwks, fetchedAt}
9
+ this.defaultCacheTtl = 3600 * 1000; // 1 hour in ms
10
+ }
11
+
12
+ async getSigningKey(token) {
13
+ // 1. Decode token header (without verification) to get kid and iss
14
+ const decoded = jwt.decode(token, { complete: true });
15
+ if (!decoded) throw new Error('Invalid token');
16
+
17
+ const { kid, alg } = decoded.header;
18
+ const iss = decoded.payload.iss;
19
+
20
+ // 2. Find matching auth server by issuer (or use first if iss is '*' or not found)
21
+ const server = this.authServers.find(s => s.issuer === iss || s.issuer === '*') || this.authServers[0];
22
+ if (!server) throw new Error('No auth server configured for issuer: ' + iss);
23
+
24
+ // 3. Fetch JWKS (cached)
25
+ const jwks = await this.fetchJwks(server.issuer, server);
26
+
27
+ // 4. Find key by kid
28
+ const jwk = jwks.keys.find(k => k.kid === kid);
29
+ if (!jwk) throw new Error('Signing key not found for kid: ' + kid);
30
+
31
+ // 5. Convert JWK to PEM
32
+ const key = this.jwkToPem(jwk);
33
+
34
+ return { key, algorithm: alg || 'RS256' };
35
+ }
36
+
37
+ async fetchJwks(issuerKey, serverConfig) {
38
+ const cacheTtl = (serverConfig.cacheTtl || this.defaultCacheTtl / 1000) * 1000;
39
+ const cached = this.cache.get(issuerKey);
40
+
41
+ if (cached && (Date.now() - cached.fetchedAt) < cacheTtl) {
42
+ return cached.jwks;
43
+ }
44
+
45
+ const response = await fetch(serverConfig.jwksUrl);
46
+ if (!response.ok) {
47
+ throw new Error('Failed to fetch JWKS from ' + serverConfig.jwksUrl + ': ' + response.status);
48
+ }
49
+
50
+ const jwks = await response.json();
51
+ this.cache.set(issuerKey, { jwks, fetchedAt: Date.now() });
52
+
53
+ return jwks;
54
+ }
55
+
56
+ jwkToPem(jwk) {
57
+ return crypto.createPublicKey({ key: jwk, format: 'jwk' });
58
+ }
59
+ }
@@ -0,0 +1,29 @@
1
+ import rateLimit from 'express-rate-limit';
2
+
3
+ export function createRateLimiters(config = {}) {
4
+ const windowMs = config.rateLimitWindowMs || parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60 * 1000;
5
+ const uploadMax = config.rateLimitUpload || parseInt(process.env.RATE_LIMIT_UPLOAD) || 10;
6
+ const downloadMax = config.rateLimitDownload || parseInt(process.env.RATE_LIMIT_DOWNLOAD) || 60;
7
+
8
+ const keyGenerator = (req) => req.user?.id || req.ip;
9
+
10
+ const uploadLimiter = rateLimit({
11
+ windowMs,
12
+ max: uploadMax,
13
+ keyGenerator,
14
+ standardHeaders: true,
15
+ legacyHeaders: false,
16
+ message: { error: 'Too Many Requests', message: 'Upload rate limit exceeded. Try again later.' }
17
+ });
18
+
19
+ const downloadLimiter = rateLimit({
20
+ windowMs,
21
+ max: downloadMax,
22
+ keyGenerator,
23
+ standardHeaders: true,
24
+ legacyHeaders: false,
25
+ message: { error: 'Too Many Requests', message: 'Rate limit exceeded. Try again later.' }
26
+ });
27
+
28
+ return { uploadLimiter, downloadLimiter };
29
+ }
@@ -18,6 +18,7 @@ export function createDeleteRouter(storage) {
18
18
  try {
19
19
  const fileId = req.params.id;
20
20
  const userId = req.user.id;
21
+ const tenantId = req.user.tenantId;
21
22
 
22
23
  // Validate file ID format (UUID)
23
24
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -29,7 +30,7 @@ export function createDeleteRouter(storage) {
29
30
  }
30
31
 
31
32
  // Delete file from Azure (validates ownership)
32
- await storage.deleteFile(fileId, userId);
33
+ await storage.deleteFile(fileId, tenantId, userId);
33
34
 
34
35
  res.status(200).json({
35
36
  success: true,
@@ -18,6 +18,7 @@ export function createDownloadRouter(storage) {
18
18
  try {
19
19
  const fileId = req.params.id;
20
20
  const userId = req.user.id;
21
+ const tenantId = req.user.tenantId;
21
22
 
22
23
  // Validate file ID format (UUID)
23
24
  const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -29,7 +30,7 @@ export function createDownloadRouter(storage) {
29
30
  }
30
31
 
31
32
  // Download file from Azure (validates ownership)
32
- const { stream, metadata } = await storage.downloadFile(fileId, userId);
33
+ const { stream, metadata } = await storage.downloadFile(fileId, tenantId, userId);
33
34
 
34
35
  // Set response headers
35
36
  res.setHeader('Content-Type', metadata.contentType);
@@ -17,9 +17,10 @@ export function createListRouter(storage) {
17
17
  router.get('/files', async (req, res) => {
18
18
  try {
19
19
  const userId = req.user.id;
20
+ const tenantId = req.user.tenantId;
20
21
 
21
22
  // List files for current user
22
- const files = await storage.listFiles(userId);
23
+ const files = await storage.listFiles(tenantId, userId);
23
24
 
24
25
  res.status(200).json({
25
26
  success: true,
@@ -41,21 +41,30 @@ export function createUploadRouter(storage, maxFileSize = 100 * 1024 * 1024) {
41
41
  }
42
42
 
43
43
  const userId = req.user.id;
44
+ const tenantId = req.user.tenantId;
44
45
  const { buffer, originalname, mimetype } = req.file;
45
46
 
46
47
  // Upload to Azure Blob Storage
47
- const fileMetadata = await storage.uploadFile(userId, buffer, originalname, mimetype);
48
+ const fileMetadata = await storage.uploadFile(tenantId, userId, buffer, originalname, mimetype);
49
+
50
+ // Build response
51
+ const fileResponse = {
52
+ id: fileMetadata.fileId,
53
+ name: fileMetadata.originalFilename,
54
+ contentType: fileMetadata.contentType,
55
+ size: fileMetadata.size,
56
+ uploadedAt: fileMetadata.uploadedAt
57
+ };
58
+
59
+ // Include tenantId in response if present
60
+ if (tenantId) {
61
+ fileResponse.tenantId = tenantId;
62
+ }
48
63
 
49
64
  // Return success response
50
65
  res.status(201).json({
51
66
  success: true,
52
- file: {
53
- id: fileMetadata.fileId,
54
- name: fileMetadata.originalFilename,
55
- contentType: fileMetadata.contentType,
56
- size: fileMetadata.size,
57
- uploadedAt: fileMetadata.uploadedAt
58
- }
67
+ file: fileResponse
59
68
  });
60
69
  } catch (error) {
61
70
  console.error('Upload error:', error);