@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.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * OAuth Authorization Endpoint
3
+ * Handles authorization requests with PKCE
4
+ */
5
+
6
+ const { generateSecureToken } = require('./pkce');
7
+ const { authenticateUser } = require('./firebase-client');
8
+ const storage = require('./storage');
9
+
10
+ /**
11
+ * Create authorization endpoint handler
12
+ */
13
+ function createAuthorizeHandler(config) {
14
+ return async (req, res) => {
15
+ try {
16
+ // Extract and validate OAuth parameters
17
+ const {
18
+ client_id,
19
+ code_challenge,
20
+ code_challenge_method,
21
+ redirect_uri,
22
+ response_type,
23
+ scope,
24
+ state
25
+ } = req.query;
26
+
27
+ // Validate required parameters
28
+ if (!client_id) {
29
+ return sendError(res, 'invalid_request', 'Missing client_id');
30
+ }
31
+
32
+ if (!code_challenge || code_challenge_method !== 'S256') {
33
+ return sendError(res, 'invalid_request', 'PKCE with S256 is required');
34
+ }
35
+
36
+ if (!redirect_uri) {
37
+ return sendError(res, 'invalid_request', 'Missing redirect_uri');
38
+ }
39
+
40
+ if (response_type !== 'code') {
41
+ return sendError(res, 'unsupported_response_type', 'Only code response type is supported');
42
+ }
43
+
44
+ // Validate client_id and redirect_uri
45
+ if (!validateClient(client_id, redirect_uri, config)) {
46
+ return sendError(res, 'invalid_client', 'Invalid client_id or redirect_uri');
47
+ }
48
+
49
+ // Authenticate with Firebase using client credentials
50
+ // Note: In this implementation, we use email/password stored in config
51
+ // In production, you might want to store user credentials differently
52
+ const email = config.clientEmail || client_id;
53
+ const password = config.clientSecret;
54
+
55
+ if (!password) {
56
+ return sendError(res, 'server_error', 'Client authentication configuration missing');
57
+ }
58
+
59
+ 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');
65
+ }
66
+
67
+ // Generate authorization code
68
+ const authorizationCode = generateSecureToken(32);
69
+
70
+ // Store authorization code with associated data
71
+ storage.store(authorizationCode, {
72
+ clientId: client_id,
73
+ codeChallenge: code_challenge,
74
+ codeChallengeMethod: code_challenge_method,
75
+ redirectUri: redirect_uri,
76
+ scope: scope || '',
77
+ userId: authResult.user.uid,
78
+ userEmail: authResult.user.email,
79
+ state: state || ''
80
+ });
81
+
82
+ // Redirect back to client with authorization code
83
+ const redirectUrl = new URL(redirect_uri);
84
+ redirectUrl.searchParams.set('code', authorizationCode);
85
+ if (state) {
86
+ redirectUrl.searchParams.set('state', state);
87
+ }
88
+
89
+ res.redirect(redirectUrl.toString());
90
+
91
+ } catch (error) {
92
+ console.error('Authorization endpoint error:', error);
93
+ return sendError(res, 'server_error', 'Internal server error');
94
+ }
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Validate client_id and redirect_uri
100
+ */
101
+ function validateClient(clientId, redirectUri, config) {
102
+ // In production, validate against registered clients from database
103
+ // For now, check against environment variables or config
104
+ const validClientId = config.clientId || process.env.OAUTH_CLIENT_ID;
105
+ const validRedirectUri = config.redirectUri || process.env.OAUTH_REDIRECT_URI;
106
+
107
+ // Allow any client if not configured (for testing)
108
+ if (!validClientId) {
109
+ console.warn('No client validation configured - accepting all clients');
110
+ return true;
111
+ }
112
+
113
+ return clientId === validClientId && redirectUri === validRedirectUri;
114
+ }
115
+
116
+ /**
117
+ * Send OAuth error response
118
+ */
119
+ function sendError(res, error, errorDescription) {
120
+ res.status(400).json({
121
+ error,
122
+ error_description: errorDescription
123
+ });
124
+ }
125
+
126
+ module.exports = { createAuthorizeHandler };
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Firebase Authentication Client
3
+ * Handles Firebase Auth operations for OAuth flows
4
+ */
5
+
6
+ const { initializeApp } = require('firebase/app');
7
+ const { getAuth, signInWithEmailAndPassword } = require('firebase/auth');
8
+ const admin = require('firebase-admin');
9
+
10
+ let firebaseApp = null;
11
+ let firebaseAuth = null;
12
+ let adminInitialized = false;
13
+
14
+ /**
15
+ * Initialize Firebase Client SDK
16
+ */
17
+ function initializeFirebaseClient(config) {
18
+ if (!firebaseApp) {
19
+ firebaseApp = initializeApp({
20
+ apiKey: config.apiKey,
21
+ authDomain: config.authDomain,
22
+ projectId: config.projectId
23
+ });
24
+ firebaseAuth = getAuth(firebaseApp);
25
+ }
26
+ return firebaseAuth;
27
+ }
28
+
29
+ /**
30
+ * Initialize Firebase Admin SDK
31
+ */
32
+ function initializeFirebaseAdmin(config) {
33
+ if (!adminInitialized) {
34
+ // Check if running in Cloud Run with default credentials
35
+ if (process.env.K_SERVICE) {
36
+ admin.initializeApp();
37
+ } else if (config.serviceAccountPath) {
38
+ // Use service account file
39
+ const serviceAccount = require(config.serviceAccountPath);
40
+ admin.initializeApp({
41
+ credential: admin.credential.cert(serviceAccount),
42
+ projectId: config.projectId
43
+ });
44
+ } else if (config.serviceAccountJson) {
45
+ // Use service account JSON from environment variable
46
+ const serviceAccount = JSON.parse(config.serviceAccountJson);
47
+ admin.initializeApp({
48
+ credential: admin.credential.cert(serviceAccount),
49
+ projectId: config.projectId
50
+ });
51
+ } else {
52
+ throw new Error('Firebase Admin SDK configuration missing');
53
+ }
54
+ adminInitialized = true;
55
+ }
56
+ return admin.auth();
57
+ }
58
+
59
+ /**
60
+ * Authenticate user with email and password
61
+ * @param {string} email - User email
62
+ * @param {string} password - User password
63
+ * @returns {Promise<Object>} - User credential with tokens
64
+ */
65
+ async function authenticateUser(email, password) {
66
+ if (!firebaseAuth) {
67
+ throw new Error('Firebase Auth not initialized');
68
+ }
69
+
70
+ try {
71
+ const userCredential = await signInWithEmailAndPassword(
72
+ firebaseAuth,
73
+ email,
74
+ password
75
+ );
76
+
77
+ const user = userCredential.user;
78
+ const idToken = await user.getIdToken();
79
+ const refreshToken = user.refreshToken;
80
+
81
+ return {
82
+ user: {
83
+ uid: user.uid,
84
+ email: user.email,
85
+ emailVerified: user.emailVerified
86
+ },
87
+ idToken,
88
+ refreshToken
89
+ };
90
+ } catch (error) {
91
+ console.error('Firebase authentication error:', error.code, error.message);
92
+ throw new Error(`Authentication failed: ${error.message}`);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Verify Firebase ID token
98
+ * @param {string} idToken - Firebase ID token
99
+ * @returns {Promise<Object>} - Decoded token
100
+ */
101
+ async function verifyIdToken(idToken) {
102
+ if (!adminInitialized) {
103
+ throw new Error('Firebase Admin not initialized');
104
+ }
105
+
106
+ try {
107
+ const decodedToken = await admin.auth().verifyIdToken(idToken);
108
+ return decodedToken;
109
+ } catch (error) {
110
+ console.error('Token verification error:', error.code, error.message);
111
+ throw new Error(`Token verification failed: ${error.message}`);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Refresh Firebase tokens using refresh token
117
+ * @param {string} refreshToken - Firebase refresh token
118
+ * @returns {Promise<Object>} - New tokens
119
+ */
120
+ async function refreshTokens(refreshToken) {
121
+ // Firebase refresh token exchange requires REST API call
122
+ const apiKey = process.env.FIREBASE_API_KEY;
123
+
124
+ try {
125
+ const response = await fetch(
126
+ `https://securetoken.googleapis.com/v1/token?key=${apiKey}`,
127
+ {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Content-Type': 'application/json'
131
+ },
132
+ body: JSON.stringify({
133
+ grant_type: 'refresh_token',
134
+ refresh_token: refreshToken
135
+ })
136
+ }
137
+ );
138
+
139
+ if (!response.ok) {
140
+ const error = await response.json();
141
+ throw new Error(error.error?.message || 'Token refresh failed');
142
+ }
143
+
144
+ const data = await response.json();
145
+
146
+ return {
147
+ idToken: data.id_token,
148
+ refreshToken: data.refresh_token,
149
+ expiresIn: parseInt(data.expires_in)
150
+ };
151
+ } catch (error) {
152
+ console.error('Token refresh error:', error.message);
153
+ throw new Error(`Token refresh failed: ${error.message}`);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Set custom claims (roles/permissions) for a user
159
+ * Requires Firebase Admin SDK
160
+ * @param {string} uid - User's Firebase UID
161
+ * @param {Object} claims - Custom claims object (e.g., { role: 'admin', permissions: ['read', 'write'] })
162
+ * @returns {Promise<void>}
163
+ */
164
+ async function setCustomClaims(uid, claims) {
165
+ if (!adminInitialized) {
166
+ throw new Error('Firebase Admin not initialized');
167
+ }
168
+
169
+ try {
170
+ await admin.auth().setCustomUserClaims(uid, claims);
171
+ console.log(`Custom claims set for user ${uid}:`, claims);
172
+ } catch (error) {
173
+ console.error('Set custom claims error:', error.code, error.message);
174
+ throw new Error(`Failed to set custom claims: ${error.message}`);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Get custom claims for a user
180
+ * @param {string} uid - User's Firebase UID
181
+ * @returns {Promise<Object>} - User's custom claims
182
+ */
183
+ async function getCustomClaims(uid) {
184
+ if (!adminInitialized) {
185
+ throw new Error('Firebase Admin not initialized');
186
+ }
187
+
188
+ try {
189
+ const user = await admin.auth().getUser(uid);
190
+ return user.customClaims || {};
191
+ } catch (error) {
192
+ console.error('Get custom claims error:', error.code, error.message);
193
+ throw new Error(`Failed to get custom claims: ${error.message}`);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Remove custom claims from a user
199
+ * @param {string} uid - User's Firebase UID
200
+ * @returns {Promise<void>}
201
+ */
202
+ async function removeCustomClaims(uid) {
203
+ if (!adminInitialized) {
204
+ throw new Error('Firebase Admin not initialized');
205
+ }
206
+
207
+ try {
208
+ await admin.auth().setCustomUserClaims(uid, null);
209
+ console.log(`Custom claims removed for user ${uid}`);
210
+ } catch (error) {
211
+ console.error('Remove custom claims error:', error.code, error.message);
212
+ throw new Error(`Failed to remove custom claims: ${error.message}`);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Update specific custom claims (merge with existing)
218
+ * @param {string} uid - User's Firebase UID
219
+ * @param {Object} newClaims - Claims to add/update
220
+ * @returns {Promise<void>}
221
+ */
222
+ async function updateCustomClaims(uid, newClaims) {
223
+ if (!adminInitialized) {
224
+ throw new Error('Firebase Admin not initialized');
225
+ }
226
+
227
+ try {
228
+ const user = await admin.auth().getUser(uid);
229
+ const currentClaims = user.customClaims || {};
230
+ const mergedClaims = { ...currentClaims, ...newClaims };
231
+
232
+ await admin.auth().setCustomUserClaims(uid, mergedClaims);
233
+ console.log(`Custom claims updated for user ${uid}:`, mergedClaims);
234
+ } catch (error) {
235
+ console.error('Update custom claims error:', error.code, error.message);
236
+ throw new Error(`Failed to update custom claims: ${error.message}`);
237
+ }
238
+ }
239
+
240
+ module.exports = {
241
+ initializeFirebaseClient,
242
+ initializeFirebaseAdmin,
243
+ authenticateUser,
244
+ verifyIdToken,
245
+ refreshTokens,
246
+ setCustomClaims,
247
+ getCustomClaims,
248
+ removeCustomClaims,
249
+ updateCustomClaims
250
+ };
package/lib/index.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * MCP OAuth Middleware - Main Export
3
+ * Provides OAuth 2.0 middleware for MCP servers using Firebase Auth
4
+ */
5
+
6
+ const { createMetadataHandler } = require('./metadata');
7
+ const { createAuthorizeHandler } = require('./authorize');
8
+ const { createTokenHandler } = require('./token');
9
+ const {
10
+ initializeFirebaseClient,
11
+ initializeFirebaseAdmin,
12
+ setCustomClaims,
13
+ getCustomClaims,
14
+ removeCustomClaims,
15
+ updateCustomClaims
16
+ } = require('./firebase-client');
17
+ const validateToken = require('../middleware/validate-token');
18
+ const { requireRole, requirePermissions, requireAdmin } = require('../middleware/validate-token');
19
+
20
+ /**
21
+ * Create OAuth middleware with configuration
22
+ * @param {Object} config - Configuration options
23
+ * @param {Object} config.firebase - Firebase configuration
24
+ * @param {string} config.firebase.apiKey - Firebase API key
25
+ * @param {string} config.firebase.authDomain - Firebase auth domain
26
+ * @param {string} config.firebase.projectId - Firebase project ID
27
+ * @param {string} config.firebase.serviceAccountPath - Path to service account JSON (optional)
28
+ * @param {string} config.firebase.serviceAccountJson - Service account JSON string (optional)
29
+ * @param {string} config.baseUrl - Base URL for OAuth endpoints (optional, auto-detected)
30
+ * @param {string} config.clientId - OAuth client ID for validation (optional)
31
+ * @param {string} config.clientSecret - Client secret / user password
32
+ * @param {string} config.clientEmail - Client email for Firebase auth
33
+ * @param {string} config.redirectUri - Allowed redirect URI (optional)
34
+ */
35
+ function createOAuthMiddleware(config) {
36
+ if (!config || !config.firebase) {
37
+ throw new Error('Firebase configuration is required');
38
+ }
39
+
40
+ // Initialize Firebase
41
+ initializeFirebaseClient(config.firebase);
42
+ initializeFirebaseAdmin(config.firebase);
43
+
44
+ // Create handlers
45
+ const metadata = createMetadataHandler(config);
46
+ const authorize = createAuthorizeHandler(config);
47
+ const token = createTokenHandler(config);
48
+
49
+ return {
50
+ metadata,
51
+ authorize,
52
+ token,
53
+ validateToken,
54
+ requireRole,
55
+ requirePermissions,
56
+ requireAdmin
57
+ };
58
+ }
59
+
60
+ module.exports = {
61
+ createOAuthMiddleware,
62
+ validateToken,
63
+ requireRole,
64
+ requirePermissions,
65
+ requireAdmin,
66
+ setCustomClaims,
67
+ getCustomClaims,
68
+ removeCustomClaims,
69
+ updateCustomClaims
70
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * OAuth Authorization Server Metadata Endpoint
3
+ * Returns server metadata according to RFC 8414
4
+ */
5
+
6
+ function createMetadataHandler(config) {
7
+ return (req, res) => {
8
+ // Auto-detect base URL from request (for Cloud Run)
9
+ const baseUrl = config.baseUrl || getBaseUrl(req);
10
+
11
+ const metadata = {
12
+ issuer: baseUrl,
13
+ authorization_endpoint: `${baseUrl}/oauth/authorize`,
14
+ token_endpoint: `${baseUrl}/oauth/token`,
15
+ grant_types_supported: ['authorization_code', 'refresh_token'],
16
+ response_types_supported: ['code'],
17
+ code_challenge_methods_supported: ['S256'],
18
+ token_endpoint_auth_methods_supported: ['none'],
19
+ scopes_supported: ['openid', 'profile', 'email']
20
+ };
21
+
22
+ res.json(metadata);
23
+ };
24
+ }
25
+
26
+ /**
27
+ * Helper to construct base URL from request headers
28
+ * Works with Cloud Run's X-Forwarded-* headers
29
+ */
30
+ function getBaseUrl(req) {
31
+ const protocol = req.get('x-forwarded-proto') || req.protocol || 'https';
32
+ const host = req.get('host');
33
+ return `${protocol}://${host}`;
34
+ }
35
+
36
+ module.exports = { createMetadataHandler, getBaseUrl };
package/lib/pkce.js ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * PKCE (Proof Key for Code Exchange) Utilities
3
+ * Implements SHA256 validation for OAuth 2.0 PKCE extension
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+
8
+ /**
9
+ * Validate PKCE code verifier against code challenge
10
+ * @param {string} codeVerifier - The code verifier from token request
11
+ * @param {string} codeChallenge - The stored code challenge from authorization request
12
+ * @param {string} method - Challenge method (should be 'S256')
13
+ * @returns {boolean} - True if verification succeeds
14
+ */
15
+ function validatePKCE(codeVerifier, codeChallenge, method = 'S256') {
16
+ if (method !== 'S256') {
17
+ throw new Error('Only S256 code challenge method is supported');
18
+ }
19
+
20
+ const hash = crypto
21
+ .createHash('sha256')
22
+ .update(codeVerifier)
23
+ .digest('base64url');
24
+
25
+ return hash === codeChallenge;
26
+ }
27
+
28
+ /**
29
+ * Generate a cryptographically secure random string
30
+ * @param {number} length - Length in bytes (default 32)
31
+ * @returns {string} - Base64url encoded random string
32
+ */
33
+ function generateSecureToken(length = 32) {
34
+ return crypto.randomBytes(length).toString('base64url');
35
+ }
36
+
37
+ /**
38
+ * Generate code challenge from verifier (for testing)
39
+ * @param {string} codeVerifier - The code verifier
40
+ * @returns {string} - Base64url encoded SHA256 hash
41
+ */
42
+ function generateCodeChallenge(codeVerifier) {
43
+ return crypto
44
+ .createHash('sha256')
45
+ .update(codeVerifier)
46
+ .digest('base64url');
47
+ }
48
+
49
+ module.exports = {
50
+ validatePKCE,
51
+ generateSecureToken,
52
+ generateCodeChallenge
53
+ };
package/lib/storage.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * In-Memory Storage for Authorization Codes and State
3
+ * For production with multiple instances, consider using Redis
4
+ */
5
+
6
+ class AuthCodeStorage {
7
+ constructor() {
8
+ this.codes = new Map();
9
+ this.cleanupInterval = null;
10
+
11
+ // Start cleanup interval (every 5 minutes)
12
+ this.startCleanup();
13
+ }
14
+
15
+ /**
16
+ * Store authorization code with associated data
17
+ * @param {string} code - Authorization code
18
+ * @param {Object} data - Associated data
19
+ * @param {number} ttl - Time to live in milliseconds (default 10 minutes)
20
+ */
21
+ store(code, data, ttl = 10 * 60 * 1000) {
22
+ this.codes.set(code, {
23
+ ...data,
24
+ expiresAt: Date.now() + ttl,
25
+ used: false
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Retrieve and mark code as used (single-use only)
31
+ * @param {string} code - Authorization code
32
+ * @returns {Object|null} - Stored data or null if invalid/expired
33
+ */
34
+ consume(code) {
35
+ const data = this.codes.get(code);
36
+
37
+ if (!data) {
38
+ return null;
39
+ }
40
+
41
+ // Check expiration
42
+ if (Date.now() > data.expiresAt) {
43
+ this.codes.delete(code);
44
+ return null;
45
+ }
46
+
47
+ // Check if already used
48
+ if (data.used) {
49
+ this.codes.delete(code);
50
+ return null;
51
+ }
52
+
53
+ // Mark as used and return
54
+ data.used = true;
55
+ this.codes.set(code, data);
56
+
57
+ // Delete after short grace period to prevent replay
58
+ setTimeout(() => this.codes.delete(code), 5000);
59
+
60
+ return data;
61
+ }
62
+
63
+ /**
64
+ * Remove expired codes from storage
65
+ */
66
+ cleanup() {
67
+ const now = Date.now();
68
+ for (const [code, data] of this.codes.entries()) {
69
+ if (now > data.expiresAt) {
70
+ this.codes.delete(code);
71
+ }
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Start automatic cleanup interval
77
+ */
78
+ startCleanup() {
79
+ if (this.cleanupInterval) {
80
+ return;
81
+ }
82
+ this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1000);
83
+ }
84
+
85
+ /**
86
+ * Stop cleanup interval (for graceful shutdown)
87
+ */
88
+ stopCleanup() {
89
+ if (this.cleanupInterval) {
90
+ clearInterval(this.cleanupInterval);
91
+ this.cleanupInterval = null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Get storage statistics (for monitoring)
97
+ */
98
+ getStats() {
99
+ return {
100
+ totalCodes: this.codes.size,
101
+ timestamp: new Date().toISOString()
102
+ };
103
+ }
104
+ }
105
+
106
+ // Singleton instance
107
+ const storage = new AuthCodeStorage();
108
+
109
+ module.exports = storage;