@connexa/mcp-oauth-firebase-middleware 1.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/README.md +632 -0
- package/lib/authorize.js +126 -0
- package/lib/firebase-client.js +250 -0
- package/lib/index.js +70 -0
- package/lib/metadata.js +36 -0
- package/lib/pkce.js +53 -0
- package/lib/storage.js +109 -0
- package/lib/token.js +150 -0
- package/middleware/validate-token.js +127 -0
- package/package.json +51 -0
package/lib/token.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth Token Endpoint
|
|
3
|
+
* Handles token exchange and refresh flows
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { validatePKCE } = require('./pkce');
|
|
7
|
+
const { authenticateUser, refreshTokens } = require('./firebase-client');
|
|
8
|
+
const storage = require('./storage');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create token endpoint handler
|
|
12
|
+
*/
|
|
13
|
+
function createTokenHandler(config) {
|
|
14
|
+
return async (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const { grant_type } = req.body;
|
|
17
|
+
|
|
18
|
+
if (!grant_type) {
|
|
19
|
+
return sendError(res, 400, 'invalid_request', 'Missing grant_type');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (grant_type === 'authorization_code') {
|
|
23
|
+
return handleAuthorizationCodeGrant(req, res, config);
|
|
24
|
+
} else if (grant_type === 'refresh_token') {
|
|
25
|
+
return handleRefreshTokenGrant(req, res, config);
|
|
26
|
+
} else {
|
|
27
|
+
return sendError(res, 400, 'unsupported_grant_type', `Grant type ${grant_type} not supported`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error('Token endpoint error:', error);
|
|
32
|
+
return sendError(res, 500, 'server_error', 'Internal server error');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Handle authorization code grant
|
|
39
|
+
*/
|
|
40
|
+
async function handleAuthorizationCodeGrant(req, res, config) {
|
|
41
|
+
const {
|
|
42
|
+
code,
|
|
43
|
+
client_id,
|
|
44
|
+
code_verifier,
|
|
45
|
+
redirect_uri
|
|
46
|
+
} = req.body;
|
|
47
|
+
|
|
48
|
+
// Validate required parameters
|
|
49
|
+
if (!code || !client_id || !code_verifier || !redirect_uri) {
|
|
50
|
+
return sendError(res, 400, 'invalid_request', 'Missing required parameters');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Retrieve and consume authorization code
|
|
54
|
+
const codeData = storage.consume(code);
|
|
55
|
+
|
|
56
|
+
if (!codeData) {
|
|
57
|
+
return sendError(res, 400, 'invalid_grant', 'Invalid or expired authorization code');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Validate client_id matches
|
|
61
|
+
if (codeData.clientId !== client_id) {
|
|
62
|
+
return sendError(res, 400, 'invalid_grant', 'client_id mismatch');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Validate redirect_uri matches
|
|
66
|
+
if (codeData.redirectUri !== redirect_uri) {
|
|
67
|
+
return sendError(res, 400, 'invalid_grant', 'redirect_uri mismatch');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Validate PKCE
|
|
71
|
+
try {
|
|
72
|
+
const isValid = validatePKCE(
|
|
73
|
+
code_verifier,
|
|
74
|
+
codeData.codeChallenge,
|
|
75
|
+
codeData.codeChallengeMethod
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (!isValid) {
|
|
79
|
+
return sendError(res, 400, 'invalid_grant', 'PKCE validation failed');
|
|
80
|
+
}
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return sendError(res, 400, 'invalid_grant', error.message);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Re-authenticate with Firebase to get fresh tokens
|
|
86
|
+
// Use stored user credentials
|
|
87
|
+
const email = codeData.userEmail;
|
|
88
|
+
const password = config.clientSecret;
|
|
89
|
+
|
|
90
|
+
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');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Return tokens
|
|
99
|
+
res.json({
|
|
100
|
+
access_token: authResult.idToken,
|
|
101
|
+
token_type: 'Bearer',
|
|
102
|
+
expires_in: 3600,
|
|
103
|
+
refresh_token: authResult.refreshToken,
|
|
104
|
+
scope: codeData.scope
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Handle refresh token grant
|
|
110
|
+
*/
|
|
111
|
+
async function handleRefreshTokenGrant(req, res, config) {
|
|
112
|
+
const {
|
|
113
|
+
refresh_token,
|
|
114
|
+
client_id
|
|
115
|
+
} = req.body;
|
|
116
|
+
|
|
117
|
+
// Validate required parameters
|
|
118
|
+
if (!refresh_token || !client_id) {
|
|
119
|
+
return sendError(res, 400, 'invalid_request', 'Missing required parameters');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Refresh Firebase tokens
|
|
123
|
+
let tokens;
|
|
124
|
+
try {
|
|
125
|
+
tokens = await refreshTokens(refresh_token);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error('Token refresh error:', error.message);
|
|
128
|
+
return sendError(res, 400, 'invalid_grant', 'Invalid refresh token');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Return new tokens
|
|
132
|
+
res.json({
|
|
133
|
+
access_token: tokens.idToken,
|
|
134
|
+
token_type: 'Bearer',
|
|
135
|
+
expires_in: tokens.expiresIn,
|
|
136
|
+
refresh_token: tokens.refreshToken
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Send error response
|
|
142
|
+
*/
|
|
143
|
+
function sendError(res, statusCode, error, errorDescription) {
|
|
144
|
+
res.status(statusCode).json({
|
|
145
|
+
error,
|
|
146
|
+
error_description: errorDescription
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { createTokenHandler };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Validation Middleware
|
|
3
|
+
* Validates Firebase ID tokens for MCP endpoint protection
|
|
4
|
+
* Supports role-based and permission-based access control via custom claims
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { verifyIdToken } = require('../lib/firebase-client');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Middleware to validate Bearer token in Authorization header
|
|
11
|
+
* @param {Object} options - Optional validation options
|
|
12
|
+
* @param {string} options.role - Required role (checks custom claim)
|
|
13
|
+
* @param {string|string[]} options.permissions - Required permission(s) (checks custom claim)
|
|
14
|
+
* @param {Function} options.customCheck - Custom validation function(decodedToken) => boolean
|
|
15
|
+
*/
|
|
16
|
+
function validateToken(options = {}) {
|
|
17
|
+
// Support both validateToken() and validateToken(options)
|
|
18
|
+
return function(req, res, next) {
|
|
19
|
+
const authHeader = req.headers.authorization;
|
|
20
|
+
|
|
21
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
22
|
+
return res.status(401).json({
|
|
23
|
+
error: 'unauthorized',
|
|
24
|
+
error_description: 'Missing or invalid Authorization header'
|
|
25
|
+
}).set('WWW-Authenticate', 'Bearer realm="MCP Server"');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
|
|
29
|
+
|
|
30
|
+
verifyIdToken(token)
|
|
31
|
+
.then(decodedToken => {
|
|
32
|
+
// Attach user info to request
|
|
33
|
+
req.user = {
|
|
34
|
+
uid: decodedToken.uid,
|
|
35
|
+
email: decodedToken.email,
|
|
36
|
+
emailVerified: decodedToken.email_verified,
|
|
37
|
+
role: decodedToken.role,
|
|
38
|
+
permissions: decodedToken.permissions || [],
|
|
39
|
+
customClaims: decodedToken
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Check role requirement
|
|
43
|
+
if (options.role && decodedToken.role !== options.role) {
|
|
44
|
+
return res.status(403).json({
|
|
45
|
+
error: 'insufficient_permissions',
|
|
46
|
+
error_description: `Required role: ${options.role}`
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check permission requirement(s)
|
|
51
|
+
if (options.permissions) {
|
|
52
|
+
const userPermissions = decodedToken.permissions || [];
|
|
53
|
+
const requiredPermissions = Array.isArray(options.permissions)
|
|
54
|
+
? options.permissions
|
|
55
|
+
: [options.permissions];
|
|
56
|
+
|
|
57
|
+
const hasAllPermissions = requiredPermissions.every(perm =>
|
|
58
|
+
userPermissions.includes(perm)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (!hasAllPermissions) {
|
|
62
|
+
return res.status(403).json({
|
|
63
|
+
error: 'insufficient_permissions',
|
|
64
|
+
error_description: `Required permissions: ${requiredPermissions.join(', ')}`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Custom validation check
|
|
70
|
+
if (options.customCheck && typeof options.customCheck === 'function') {
|
|
71
|
+
try {
|
|
72
|
+
const isValid = options.customCheck(decodedToken);
|
|
73
|
+
if (!isValid) {
|
|
74
|
+
return res.status(403).json({
|
|
75
|
+
error: 'insufficient_permissions',
|
|
76
|
+
error_description: 'Custom authorization check failed'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error('Custom check error:', error.message);
|
|
81
|
+
return res.status(403).json({
|
|
82
|
+
error: 'insufficient_permissions',
|
|
83
|
+
error_description: 'Authorization check failed'
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
next();
|
|
89
|
+
})
|
|
90
|
+
.catch(error => {
|
|
91
|
+
console.error('Token validation failed:', error.message);
|
|
92
|
+
return res.status(401).json({
|
|
93
|
+
error: 'invalid_token',
|
|
94
|
+
error_description: 'Token validation failed'
|
|
95
|
+
}).set('WWW-Authenticate', 'Bearer realm="MCP Server", error="invalid_token"');
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Convenience middleware: Require specific role
|
|
102
|
+
* @param {string} role - Required role name
|
|
103
|
+
*/
|
|
104
|
+
function requireRole(role) {
|
|
105
|
+
return validateToken({ role });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convenience middleware: Require one or more permissions
|
|
110
|
+
* @param {string|string[]} permissions - Required permission(s)
|
|
111
|
+
*/
|
|
112
|
+
function requirePermissions(permissions) {
|
|
113
|
+
return validateToken({ permissions });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Convenience middleware: Require admin role
|
|
118
|
+
*/
|
|
119
|
+
function requireAdmin() {
|
|
120
|
+
return validateToken({ role: 'admin' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = validateToken;
|
|
124
|
+
module.exports.validateToken = validateToken;
|
|
125
|
+
module.exports.requireRole = requireRole;
|
|
126
|
+
module.exports.requirePermissions = requirePermissions;
|
|
127
|
+
module.exports.requireAdmin = requireAdmin;
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@connexa/mcp-oauth-firebase-middleware",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OAuth 2.0 middleware for MCP servers using Firebase Auth backend",
|
|
5
|
+
"private": false,
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "node server.js",
|
|
9
|
+
"dev": "node server.js",
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"oauth",
|
|
14
|
+
"oauth2",
|
|
15
|
+
"firebase",
|
|
16
|
+
"mcp",
|
|
17
|
+
"model-context-protocol",
|
|
18
|
+
"authentication",
|
|
19
|
+
"middleware",
|
|
20
|
+
"pkce"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/yourusername/mcp-oauth-firebase-middleware"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "restricted"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"lib/",
|
|
33
|
+
"middleware/",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"firebase": "^10.7.1",
|
|
38
|
+
"firebase-admin": "^12.0.0"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"express": "^4.18.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"express": "^4.18.2",
|
|
45
|
+
"dotenv": "^16.3.1",
|
|
46
|
+
"cors": "^2.8.5"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|