@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/authorize.js
ADDED
|
@@ -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
|
+
};
|
package/lib/metadata.js
ADDED
|
@@ -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;
|