@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 +19 -5
- package/lib/firebase-client.js +6 -2
- package/lib/token-cache.js +148 -0
- package/lib/token.js +35 -5
- package/middleware/validate-token.js +4 -2
- package/package.json +27 -8
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
package/lib/firebase-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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.
|
|
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": "
|
|
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/
|
|
29
|
+
"url": "git+https://github.com/connexa/mcp-oauth-firebase-middleware.git"
|
|
27
30
|
},
|
|
28
31
|
"publishConfig": {
|
|
29
|
-
"access": "
|
|
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
|
-
"
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
|
+
"axios": "^1.13.2",
|
|
49
|
+
"cors": "^2.8.5",
|
|
45
50
|
"dotenv": "^16.3.1",
|
|
46
|
-
"
|
|
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"
|