@connexa/mcp-oauth-firebase-middleware 1.0.0 → 1.0.2

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/lib/authorize.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const { generateSecureToken } = require('./pkce');
7
7
  const { authenticateUser } = require('./firebase-client');
8
8
  const storage = require('./storage');
9
+ const tokenCache = require('./token-cache');
9
10
 
10
11
  /**
11
12
  * Create authorization endpoint handler
@@ -52,16 +53,29 @@ function createAuthorizeHandler(config) {
52
53
  const email = config.clientEmail || client_id;
53
54
  const password = config.clientSecret;
54
55
 
56
+ console.log('[DEBUG] Authorize endpoint - email:', email, 'has password:', !!password);
57
+
55
58
  if (!password) {
56
59
  return sendError(res, 'server_error', 'Client authentication configuration missing');
57
60
  }
58
61
 
59
62
  let authResult;
60
- try {
61
- authResult = await authenticateUser(email, password);
62
- } catch (error) {
63
- console.error('Firebase auth error in authorize:', error.message);
64
- return sendError(res, 'access_denied', 'Authentication failed');
63
+
64
+ // Check token cache first to avoid hitting Firebase quota
65
+ const cachedAuth = tokenCache.get(email);
66
+ if (cachedAuth) {
67
+ console.log('[DEBUG] Using cached token for:', email);
68
+ authResult = cachedAuth;
69
+ } else {
70
+ console.log('[DEBUG] No cached token, authenticating with Firebase for:', email);
71
+ try {
72
+ authResult = await authenticateUser(email, password);
73
+ // Cache the result for future requests
74
+ tokenCache.set(email, authResult);
75
+ } catch (error) {
76
+ console.error('Firebase auth error in authorize:', error.message, error.code);
77
+ return sendError(res, 'access_denied', 'Authentication failed');
78
+ }
65
79
  }
66
80
 
67
81
  // Generate authorization code
@@ -3,6 +3,7 @@
3
3
  * Handles Firebase Auth operations for OAuth flows
4
4
  */
5
5
 
6
+ const path = require('path');
6
7
  const { initializeApp } = require('firebase/app');
7
8
  const { getAuth, signInWithEmailAndPassword } = require('firebase/auth');
8
9
  const admin = require('firebase-admin');
@@ -35,8 +36,11 @@ function initializeFirebaseAdmin(config) {
35
36
  if (process.env.K_SERVICE) {
36
37
  admin.initializeApp();
37
38
  } else if (config.serviceAccountPath) {
38
- // Use service account file
39
- const serviceAccount = require(config.serviceAccountPath);
39
+ // Use service account file - resolve relative paths from project root
40
+ const absolutePath = path.isAbsolute(config.serviceAccountPath)
41
+ ? config.serviceAccountPath
42
+ : path.resolve(process.cwd(), config.serviceAccountPath);
43
+ const serviceAccount = require(absolutePath);
40
44
  admin.initializeApp({
41
45
  credential: admin.credential.cert(serviceAccount),
42
46
  projectId: config.projectId
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Token Cache
3
+ * Caches Firebase ID tokens by user email to reduce authentication calls
4
+ * Helps avoid hitting Firebase quota limits during testing
5
+ * Uses file-based storage for cross-process sharing
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ class TokenCache {
13
+ constructor() {
14
+ this.cache = new Map();
15
+ this.cacheFile = path.join(os.tmpdir(), 'firebase-token-cache.json');
16
+ this.loadFromFile();
17
+ }
18
+
19
+ /**
20
+ * Load cache from file (for cross-process sharing)
21
+ */
22
+ loadFromFile() {
23
+ try {
24
+ if (fs.existsSync(this.cacheFile)) {
25
+ const data = fs.readFileSync(this.cacheFile, 'utf8');
26
+ const parsed = JSON.parse(data);
27
+
28
+ // Clear existing cache and load from file (file is source of truth)
29
+ this.cache.clear();
30
+
31
+ // Restore Map from object
32
+ Object.keys(parsed).forEach(email => {
33
+ // Check if not expired
34
+ if (Date.now() < parsed[email].expiresAt) {
35
+ this.cache.set(email, parsed[email]);
36
+ }
37
+ });
38
+ } else {
39
+ // No file exists, start with empty cache
40
+ this.cache.clear();
41
+ }
42
+ } catch (error) {
43
+ // Ignore errors, will create new cache
44
+ console.warn('Failed to load token cache from file:', error.message);
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Save cache to file (for cross-process sharing)
50
+ */
51
+ saveToFile() {
52
+ try {
53
+ const obj = {};
54
+ this.cache.forEach((value, key) => {
55
+ obj[key] = value;
56
+ });
57
+ fs.writeFileSync(this.cacheFile, JSON.stringify(obj), 'utf8');
58
+ } catch (error) {
59
+ console.warn('Failed to save token cache to file:', error.message);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Store Firebase authentication result
65
+ * @param {string} email - User email
66
+ * @param {Object} authResult - Firebase auth result
67
+ * @param {string} authResult.idToken - Firebase ID token
68
+ * @param {string} authResult.refreshToken - Firebase refresh token
69
+ * @param {Object} authResult.user - User object with uid, email
70
+ * @param {number} ttl - Time to live in milliseconds (default 50 minutes, tokens expire in 1 hour)
71
+ */
72
+ set(email, authResult, ttl = 50 * 60 * 1000) {
73
+ this.cache.set(email, {
74
+ idToken: authResult.idToken,
75
+ refreshToken: authResult.refreshToken,
76
+ user: authResult.user,
77
+ expiresAt: Date.now() + ttl
78
+ });
79
+ this.saveToFile(); // Persist to file for cross-process sharing
80
+ }
81
+
82
+ /**
83
+ * Retrieve cached authentication result
84
+ * @param {string} email - User email
85
+ * @returns {Object|null} - Cached auth result or null if not found/expired
86
+ */
87
+ get(email) {
88
+ // Reload from file to get latest from other processes
89
+ this.loadFromFile();
90
+
91
+ const cached = this.cache.get(email);
92
+
93
+ if (!cached) {
94
+ console.log(`[CACHE] No cached token found for: ${email}`);
95
+ return null;
96
+ }
97
+
98
+ // Check if expired
99
+ if (Date.now() > cached.expiresAt) {
100
+ console.log(`[CACHE] Cached token expired for: ${email}`);
101
+ this.cache.delete(email);
102
+ this.saveToFile();
103
+ return null;
104
+ }
105
+
106
+ console.log(`[CACHE] Returning cached token for: ${email} (expires: ${new Date(cached.expiresAt).toISOString()})`);
107
+ return cached;
108
+ }
109
+
110
+ /**
111
+ * Clear cached token for a user
112
+ * @param {string} email - User email
113
+ */
114
+ clear(email) {
115
+ console.log(`[CACHE] Clearing token for: ${email}`);
116
+ this.cache.delete(email);
117
+ this.saveToFile();
118
+ console.log(`[CACHE] Token cleared and file saved. Cache size: ${this.cache.size}`);
119
+ }
120
+
121
+ /**
122
+ * Clear all cached tokens
123
+ */
124
+ clearAll() {
125
+ this.cache.clear();
126
+ try {
127
+ if (fs.existsSync(this.cacheFile)) {
128
+ fs.unlinkSync(this.cacheFile);
129
+ }
130
+ } catch (error) {
131
+ console.warn('Failed to delete cache file:', error.message);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Check if token exists and is valid
137
+ * @param {string} email - User email
138
+ * @returns {boolean}
139
+ */
140
+ has(email) {
141
+ return this.get(email) !== null;
142
+ }
143
+ }
144
+
145
+ // Singleton instance
146
+ const tokenCache = new TokenCache();
147
+
148
+ module.exports = tokenCache;
package/lib/token.js CHANGED
@@ -6,6 +6,7 @@
6
6
  const { validatePKCE } = require('./pkce');
7
7
  const { authenticateUser, refreshTokens } = require('./firebase-client');
8
8
  const storage = require('./storage');
9
+ const tokenCache = require('./token-cache');
9
10
 
10
11
  /**
11
12
  * Create token endpoint handler
@@ -13,6 +14,7 @@ const storage = require('./storage');
13
14
  function createTokenHandler(config) {
14
15
  return async (req, res) => {
15
16
  try {
17
+ console.log('[DEBUG] Token endpoint called with body:', JSON.stringify(req.body, null, 2));
16
18
  const { grant_type } = req.body;
17
19
 
18
20
  if (!grant_type) {
@@ -45,30 +47,43 @@ async function handleAuthorizationCodeGrant(req, res, config) {
45
47
  redirect_uri
46
48
  } = req.body;
47
49
 
50
+ console.log('[DEBUG] Token exchange request:', {
51
+ has_code: !!code,
52
+ has_client_id: !!client_id,
53
+ has_code_verifier: !!code_verifier,
54
+ has_redirect_uri: !!redirect_uri
55
+ });
56
+
48
57
  // Validate required parameters
49
58
  if (!code || !client_id || !code_verifier || !redirect_uri) {
59
+ console.log('[DEBUG] Missing parameters:', { code: !!code, client_id: !!client_id, code_verifier: !!code_verifier, redirect_uri: !!redirect_uri });
50
60
  return sendError(res, 400, 'invalid_request', 'Missing required parameters');
51
61
  }
52
62
 
53
63
  // Retrieve and consume authorization code
54
64
  const codeData = storage.consume(code);
65
+ console.log('[DEBUG] Code data retrieved:', { found: !!codeData, data: codeData ? { ...codeData, codeChallenge: '***' } : null });
55
66
 
56
67
  if (!codeData) {
68
+ console.log('[DEBUG] Code not found or expired');
57
69
  return sendError(res, 400, 'invalid_grant', 'Invalid or expired authorization code');
58
70
  }
59
71
 
60
72
  // Validate client_id matches
61
73
  if (codeData.clientId !== client_id) {
74
+ console.log('[DEBUG] client_id mismatch:', { expected: codeData.clientId, received: client_id });
62
75
  return sendError(res, 400, 'invalid_grant', 'client_id mismatch');
63
76
  }
64
77
 
65
78
  // Validate redirect_uri matches
66
79
  if (codeData.redirectUri !== redirect_uri) {
80
+ console.log('[DEBUG] redirect_uri mismatch:', { expected: codeData.redirectUri, received: redirect_uri });
67
81
  return sendError(res, 400, 'invalid_grant', 'redirect_uri mismatch');
68
82
  }
69
83
 
70
84
  // Validate PKCE
71
85
  try {
86
+ console.log('[DEBUG] Validating PKCE with method:', codeData.codeChallengeMethod);
72
87
  const isValid = validatePKCE(
73
88
  code_verifier,
74
89
  codeData.codeChallenge,
@@ -76,9 +91,12 @@ async function handleAuthorizationCodeGrant(req, res, config) {
76
91
  );
77
92
 
78
93
  if (!isValid) {
94
+ console.log('[DEBUG] PKCE validation returned false');
79
95
  return sendError(res, 400, 'invalid_grant', 'PKCE validation failed');
80
96
  }
97
+ console.log('[DEBUG] PKCE validation passed');
81
98
  } catch (error) {
99
+ console.log('[DEBUG] PKCE validation error:', error.message);
82
100
  return sendError(res, 400, 'invalid_grant', error.message);
83
101
  }
84
102
 
@@ -88,11 +106,22 @@ async function handleAuthorizationCodeGrant(req, res, config) {
88
106
  const password = config.clientSecret;
89
107
 
90
108
  let authResult;
91
- try {
92
- authResult = await authenticateUser(email, password);
93
- } catch (error) {
94
- console.error('Firebase auth error in token exchange:', error.message);
95
- return sendError(res, 400, 'invalid_grant', 'Failed to issue tokens');
109
+
110
+ // Check token cache first to avoid hitting Firebase quota
111
+ const cachedAuth = tokenCache.get(email);
112
+ if (cachedAuth) {
113
+ console.log('[DEBUG] Token endpoint: Using cached token for:', email);
114
+ authResult = cachedAuth;
115
+ } else {
116
+ console.log('[DEBUG] Token endpoint: No cached token, authenticating with Firebase for:', email);
117
+ try {
118
+ authResult = await authenticateUser(email, password);
119
+ // Cache the result for future requests
120
+ tokenCache.set(email, authResult);
121
+ } catch (error) {
122
+ console.error('Firebase auth error in token exchange:', error.message);
123
+ return sendError(res, 400, 'invalid_grant', 'Failed to issue tokens');
124
+ }
96
125
  }
97
126
 
98
127
  // Return tokens
@@ -141,6 +170,7 @@ async function handleRefreshTokenGrant(req, res, config) {
141
170
  * Send error response
142
171
  */
143
172
  function sendError(res, statusCode, error, errorDescription) {
173
+ console.error(`[TOKEN ERROR] ${statusCode} - ${error}: ${errorDescription}`);
144
174
  res.status(statusCode).json({
145
175
  error,
146
176
  error_description: errorDescription
@@ -19,10 +19,11 @@ function validateToken(options = {}) {
19
19
  const authHeader = req.headers.authorization;
20
20
 
21
21
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
22
+ res.set('WWW-Authenticate', 'Bearer realm="MCP Server"');
22
23
  return res.status(401).json({
23
24
  error: 'unauthorized',
24
25
  error_description: 'Missing or invalid Authorization header'
25
- }).set('WWW-Authenticate', 'Bearer realm="MCP Server"');
26
+ });
26
27
  }
27
28
 
28
29
  const token = authHeader.substring(7); // Remove 'Bearer ' prefix
@@ -89,10 +90,11 @@ function validateToken(options = {}) {
89
90
  })
90
91
  .catch(error => {
91
92
  console.error('Token validation failed:', error.message);
93
+ res.set('WWW-Authenticate', 'Bearer realm="MCP Server", error="invalid_token"');
92
94
  return res.status(401).json({
93
95
  error: 'invalid_token',
94
96
  error_description: 'Token validation failed'
95
- }).set('WWW-Authenticate', 'Bearer realm="MCP Server", error="invalid_token"');
97
+ });
96
98
  });
97
99
  };
98
100
  }
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@connexa/mcp-oauth-firebase-middleware",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "OAuth 2.0 middleware for MCP servers using Firebase Auth backend",
5
5
  "private": false,
6
6
  "main": "lib/index.js",
7
7
  "scripts": {
8
8
  "start": "node server.js",
9
9
  "dev": "node server.js",
10
- "test": "echo \"Error: no test specified\" && exit 1"
10
+ "test": "jest --runInBand --verbose",
11
+ "test:watch": "jest --watch",
12
+ "test:coverage": "jest --coverage",
13
+ "test:server": "node test/mcp-test-server.js"
11
14
  },
12
15
  "keywords": [
13
16
  "oauth",
@@ -19,14 +22,14 @@
19
22
  "middleware",
20
23
  "pkce"
21
24
  ],
22
- "author": "",
25
+ "author": "Connexa",
23
26
  "license": "MIT",
24
27
  "repository": {
25
28
  "type": "git",
26
- "url": "https://github.com/yourusername/mcp-oauth-firebase-middleware"
29
+ "url": "git+https://github.com/connexa/mcp-oauth-firebase-middleware.git"
27
30
  },
28
31
  "publishConfig": {
29
- "access": "restricted"
32
+ "access": "public"
30
33
  },
31
34
  "files": [
32
35
  "lib/",
@@ -38,12 +41,28 @@
38
41
  "firebase-admin": "^12.0.0"
39
42
  },
40
43
  "peerDependencies": {
41
- "express": "^4.18.0"
44
+ "express": "^4.18.0 || ^5.0.0"
42
45
  },
43
46
  "devDependencies": {
44
- "express": "^4.18.2",
47
+ "@modelcontextprotocol/sdk": "^1.0.0",
48
+ "axios": "^1.13.2",
49
+ "cors": "^2.8.5",
45
50
  "dotenv": "^16.3.1",
46
- "cors": "^2.8.5"
51
+ "express": "^4.18.2",
52
+ "jest": "^29.7.0"
53
+ },
54
+ "jest": {
55
+ "testEnvironment": "node",
56
+ "testTimeout": 10000,
57
+ "testMatch": [
58
+ "**/test/integration/**/*.test.js"
59
+ ],
60
+ "setupFilesAfterEnv": [
61
+ "<rootDir>/test/setup/jest.setup.js"
62
+ ],
63
+ "verbose": true,
64
+ "forceExit": true,
65
+ "detectOpenHandles": true
47
66
  },
48
67
  "engines": {
49
68
  "node": ">=18.0.0"