backend-manager 5.0.42 → 5.0.44

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.0.42",
3
+ "version": "5.0.44",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -49,10 +49,10 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
49
49
  response: 'json',
50
50
  tries: 2,
51
51
  log: true,
52
- headers: {
53
- 'Authorization': `Bearer ${process.env.BACKEND_MANAGER_KEY}`,
52
+ body: {
53
+ uid,
54
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
54
55
  },
55
- body: { uid },
56
56
  })
57
57
  .then((json) => {
58
58
  assistant.log(`Sign out of all sessions success`, json);
@@ -62,10 +62,11 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
62
62
  });
63
63
 
64
64
  // Delete the user
65
- await admin.auth().deleteUser(uid)
66
- .catch((e) => {
67
- return assistant.respond(`Failed to delete user: ${e}`, { code: 500 });
68
- });
65
+ try {
66
+ await admin.auth().deleteUser(uid);
67
+ } catch (e) {
68
+ return assistant.respond(`Failed to delete user: ${e}`, { code: 500 });
69
+ }
69
70
 
70
71
  return assistant.respond({ success: true });
71
72
  };
@@ -0,0 +1,176 @@
1
+ const crypto = require('crypto');
2
+ const fetch = require('wonderful-fetch');
3
+ const { arrayify } = require('node-powertools');
4
+
5
+ // Constants
6
+ const STATE_TTL_MINUTES = 10;
7
+
8
+ // Derive OAuth state encryption key from BACKEND_MANAGER_KEY
9
+ const STATE_KEY = process.env.BACKEND_MANAGER_KEY
10
+ ? crypto.createHash('sha256').update(`oauth2-state:${process.env.BACKEND_MANAGER_KEY}`).digest('hex')
11
+ : null;
12
+
13
+ /**
14
+ * Build context object with common OAuth2 data
15
+ * Used by GET, POST, DELETE handlers
16
+ */
17
+ async function buildContext({ assistant, Manager, user, settings, libraries, requireProvider = true }) {
18
+ const { admin } = libraries;
19
+
20
+ // Require authentication
21
+ if (!user.authenticated) {
22
+ return { error: { message: 'Authentication required', code: 401 } };
23
+ }
24
+
25
+ // Get target user (admin can manage other users)
26
+ const targetUid = settings.uid || user.auth.uid;
27
+
28
+ if (targetUid !== user.auth.uid && !user.roles.admin) {
29
+ return { error: { message: 'Admin required to manage other users', code: 403 } };
30
+ }
31
+
32
+ // Resolve target user data
33
+ let targetUser = user;
34
+
35
+ if (targetUid !== user.auth.uid) {
36
+ const doc = await admin.firestore().doc(`users/${targetUid}`).get();
37
+
38
+ if (!doc.exists) {
39
+ return { error: { message: 'User not found', code: 404 } };
40
+ }
41
+
42
+ targetUser = doc.data();
43
+ }
44
+
45
+ // Build redirect URI
46
+ const redirectUri = assistant.isDevelopment()
47
+ ? 'https://localhost:4000/oauth2'
48
+ : `${Manager.config.brand.url}/oauth2`;
49
+
50
+ // If provider not required (e.g., tokenize gets it from encrypted state), skip loading
51
+ if (!requireProvider) {
52
+ return {
53
+ assistant,
54
+ Manager,
55
+ admin,
56
+ settings,
57
+ targetUid,
58
+ targetUser,
59
+ redirectUri,
60
+ };
61
+ }
62
+
63
+ // Provider is required
64
+ if (!settings.provider) {
65
+ return { error: { message: 'The provider parameter is required', code: 400 } };
66
+ }
67
+
68
+ // Load provider module
69
+ let oauth2Provider;
70
+
71
+ try {
72
+ oauth2Provider = require(`./providers/${settings.provider}.js`);
73
+ } catch (e) {
74
+ return { error: { message: `Unknown OAuth2 provider: ${settings.provider}`, code: 400 } };
75
+ }
76
+
77
+ // Get OAuth2 credentials
78
+ const providerEnvKey = settings.provider.toUpperCase().replace(/-/g, '_');
79
+ const clientId = process.env[`OAUTH2_${providerEnvKey}_CLIENT_ID`];
80
+ const clientSecret = process.env[`OAUTH2_${providerEnvKey}_CLIENT_SECRET`];
81
+
82
+ return {
83
+ assistant,
84
+ Manager,
85
+ admin,
86
+ oauth2Provider,
87
+ settings,
88
+ targetUid,
89
+ targetUser,
90
+ clientId,
91
+ clientSecret,
92
+ redirectUri,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Load provider and credentials from provider name
98
+ */
99
+ function loadProvider(providerName) {
100
+ let oauth2Provider;
101
+
102
+ try {
103
+ oauth2Provider = require(`./providers/${providerName}.js`);
104
+ } catch (e) {
105
+ return { error: { message: `Unknown OAuth2 provider: ${providerName}`, code: 400 } };
106
+ }
107
+
108
+ const providerEnvKey = providerName.toUpperCase().replace(/-/g, '_');
109
+ const clientId = process.env[`OAUTH2_${providerEnvKey}_CLIENT_ID`];
110
+ const clientSecret = process.env[`OAUTH2_${providerEnvKey}_CLIENT_SECRET`];
111
+
112
+ return { oauth2Provider, clientId, clientSecret };
113
+ }
114
+
115
+ // ============================================================================
116
+ // Crypto Helpers
117
+ // ============================================================================
118
+
119
+ function generateCsrfToken() {
120
+ return crypto.randomBytes(32).toString('hex');
121
+ }
122
+
123
+ function encryptState(data) {
124
+ if (!STATE_KEY) {
125
+ throw new Error('BACKEND_MANAGER_KEY not configured');
126
+ }
127
+
128
+ const iv = crypto.randomBytes(16);
129
+ const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(STATE_KEY, 'hex'), iv);
130
+
131
+ let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'base64');
132
+ encrypted += cipher.final('base64');
133
+
134
+ const authTag = cipher.getAuthTag().toString('base64');
135
+
136
+ return `${iv.toString('base64')}.${encrypted}.${authTag}`;
137
+ }
138
+
139
+ function decryptState(encryptedState) {
140
+ if (!STATE_KEY) {
141
+ throw new Error('BACKEND_MANAGER_KEY not configured');
142
+ }
143
+
144
+ const parts = encryptedState.split('.');
145
+
146
+ if (parts.length !== 3) {
147
+ throw new Error('Invalid state format');
148
+ }
149
+
150
+ const [ivB64, encryptedB64, authTagB64] = parts;
151
+
152
+ const iv = Buffer.from(ivB64, 'base64');
153
+ const encrypted = Buffer.from(encryptedB64, 'base64');
154
+ const authTag = Buffer.from(authTagB64, 'base64');
155
+
156
+ const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(STATE_KEY, 'hex'), iv);
157
+ decipher.setAuthTag(authTag);
158
+
159
+ let decrypted = decipher.update(encrypted, 'base64', 'utf8');
160
+ decrypted += decipher.final('utf8');
161
+
162
+ return JSON.parse(decrypted);
163
+ }
164
+
165
+ module.exports = {
166
+ STATE_TTL_MINUTES,
167
+ STATE_KEY,
168
+ buildContext,
169
+ loadProvider,
170
+ generateCsrfToken,
171
+ encryptState,
172
+ decryptState,
173
+ // Re-export utilities for handlers
174
+ fetch,
175
+ arrayify,
176
+ };
@@ -0,0 +1,42 @@
1
+ const {
2
+ buildContext,
3
+ } = require('./_helpers.js');
4
+
5
+ /**
6
+ * DELETE /user/oauth2 - Remove OAuth connection
7
+ *
8
+ * Revokes tokens with the provider (best effort) and removes the connection.
9
+ */
10
+ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
11
+ const context = await buildContext({ assistant, Manager, user, settings, libraries });
12
+
13
+ if (context.error) {
14
+ return assistant.respond(context.error.message, { code: context.error.code });
15
+ }
16
+
17
+ const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
18
+
19
+ assistant.log('OAuth2 DELETE request', { provider: settings.provider });
20
+
21
+ // Get current access token to revoke
22
+ const accessToken = targetUser?.oauth2?.[settings.provider]?.token?.access_token;
23
+
24
+ // Attempt to revoke token with provider (best effort)
25
+ if (accessToken && oauth2Provider.revokeToken) {
26
+ const revokeResult = await oauth2Provider.revokeToken(accessToken, {
27
+ assistant,
28
+ clientId,
29
+ clientSecret,
30
+ }).catch(e => ({ revoked: false, reason: e.message }));
31
+
32
+ assistant.log('Token revocation result:', revokeResult);
33
+ }
34
+
35
+ // Delete OAuth data from user document
36
+ await admin.firestore().doc(`users/${targetUid}`).update({
37
+ [`oauth2.${settings.provider}`]: admin.firestore.FieldValue.delete(),
38
+ metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
39
+ });
40
+
41
+ return assistant.respond({ success: true });
42
+ };
@@ -0,0 +1,132 @@
1
+ const {
2
+ buildContext,
3
+ generateCsrfToken,
4
+ encryptState,
5
+ } = require('./_helpers.js');
6
+
7
+ /**
8
+ * GET /user/oauth2 - Read operations
9
+ *
10
+ * Actions:
11
+ * - authorize (default): Get authorization URL
12
+ * - status: Check connection status
13
+ */
14
+ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
15
+ const context = await buildContext({ assistant, Manager, user, settings, libraries });
16
+
17
+ if (context.error) {
18
+ return assistant.respond(context.error.message, { code: context.error.code });
19
+ }
20
+
21
+ const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret, redirectUri } = context;
22
+
23
+ assistant.log('OAuth2 GET request', { action: settings.action, provider: settings.provider });
24
+
25
+ switch (settings.action) {
26
+ case 'status':
27
+ return processStatus(context);
28
+
29
+ case 'authorize':
30
+ default:
31
+ return processAuthorize(context);
32
+ }
33
+ };
34
+
35
+ // ============================================================================
36
+ // Handlers
37
+ // ============================================================================
38
+
39
+ async function processAuthorize(context) {
40
+ const { assistant, Manager, admin, oauth2Provider, settings, targetUid, clientId, redirectUri } = context;
41
+
42
+ if (!clientId) {
43
+ return assistant.respond(`Missing client_id for ${settings.provider} provider`, { code: 500 });
44
+ }
45
+
46
+ // Generate CSRF token
47
+ const csrfToken = generateCsrfToken();
48
+
49
+ // Store CSRF token in user's usage document (auto-cleaned daily)
50
+ await admin.firestore().doc(`usage/${targetUid}`).set({
51
+ oauth2: {
52
+ [settings.provider]: {
53
+ csrf: csrfToken,
54
+ createdAt: Date.now(),
55
+ },
56
+ },
57
+ }, { merge: true });
58
+
59
+ // Build minimal state (no unnecessary data)
60
+ const stateData = {
61
+ provider: settings.provider,
62
+ uid: targetUid,
63
+ csrf: csrfToken,
64
+ ts: Date.now(),
65
+ };
66
+
67
+ // Encrypt state
68
+ let encryptedState;
69
+
70
+ try {
71
+ encryptedState = encryptState(stateData);
72
+ } catch (e) {
73
+ return assistant.respond(e.message, { code: 500 });
74
+ }
75
+
76
+ // Build authorization URL
77
+ const url = new URL(oauth2Provider.urls.authorize);
78
+ url.searchParams.set('state', encryptedState);
79
+ url.searchParams.set('client_id', clientId);
80
+ url.searchParams.set('redirect_uri', redirectUri);
81
+ url.searchParams.set('response_type', 'code');
82
+
83
+ // Set scopes from app config, fall back to provider defaults
84
+ const appScopes = Manager.config?.oauth2?.[settings.provider]?.scope || [];
85
+ const finalScopes = appScopes.length > 0 ? appScopes : (oauth2Provider.scope || []);
86
+ url.searchParams.set('scope', finalScopes.join(' '));
87
+
88
+ // Add provider-specific auth params
89
+ const authParams = oauth2Provider.authParams || {};
90
+
91
+ for (const [key, value] of Object.entries(authParams)) {
92
+ url.searchParams.set(key, value);
93
+ }
94
+
95
+ const urlString = url.toString();
96
+
97
+ assistant.log('OAuth2 authorize URL generated');
98
+
99
+ if (settings.redirect) {
100
+ return assistant.redirect(urlString);
101
+ }
102
+
103
+ return assistant.respond({ url: urlString });
104
+ }
105
+
106
+ async function processStatus(context) {
107
+ const { assistant, Manager, admin, oauth2Provider, settings, targetUid, targetUser, clientId, clientSecret } = context;
108
+
109
+ const token = targetUser?.oauth2?.[settings.provider]?.token?.refresh_token;
110
+
111
+ if (!token) {
112
+ return assistant.respond({ status: 'disconnected' });
113
+ }
114
+
115
+ // Verify connection if provider supports it
116
+ if (oauth2Provider.verifyConnection) {
117
+ const status = await oauth2Provider.verifyConnection(token, { Manager, assistant, clientId, clientSecret })
118
+ .catch(() => 'error');
119
+
120
+ if ((status === 'disconnected' || status === 'error') && settings.removeInvalidTokens) {
121
+ await admin.firestore().doc(`users/${targetUid}`).update({
122
+ [`oauth2.${settings.provider}`]: admin.firestore.FieldValue.delete(),
123
+ metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
124
+ });
125
+ assistant.log(`Removed invalid token for user: ${targetUid}`);
126
+ }
127
+
128
+ return assistant.respond({ status });
129
+ }
130
+
131
+ return assistant.respond({ status: 'connected' });
132
+ }
@@ -1,326 +1,226 @@
1
- const _ = require('lodash');
2
- const fetch = require('wonderful-fetch');
3
- const { arrayify } = require('node-powertools');
1
+ const {
2
+ buildContext,
3
+ loadProvider,
4
+ decryptState,
5
+ STATE_TTL_MINUTES,
6
+ fetch,
7
+ } = require('./_helpers.js');
4
8
 
5
9
  /**
6
- * POST /user/oauth2 - OAuth2 operations
10
+ * POST /user/oauth2 - Write operations
7
11
  *
8
- * States:
9
- * - authorize: Get authorization URL
10
- * - tokenize: Exchange code for tokens
12
+ * Actions:
13
+ * - tokenize (default): Exchange authorization code for tokens
11
14
  * - refresh: Refresh access token
12
- * - deauthorize: Remove OAuth2 connection
13
- * - status: Check connection status
14
15
  */
15
16
  module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
16
17
  const { admin } = libraries;
17
18
 
18
- // Require authentication
19
- if (!user.authenticated) {
20
- return assistant.respond('Authentication required', { code: 401 });
21
- }
22
-
23
- // Require admin to manage other users' OAuth
24
- const uid = settings.uid;
25
-
26
- if (uid !== user.auth.uid && !user.roles.admin) {
27
- return assistant.respond('Admin required', { code: 403 });
28
- }
29
-
30
- // Get target user data
31
- let userData = user;
19
+ assistant.log('OAuth2 POST request', { action: settings.action });
32
20
 
33
- if (uid !== user.auth.uid) {
34
- const doc = await admin.firestore().doc(`users/${uid}`).get();
35
-
36
- if (!doc.exists) {
37
- return assistant.respond('User not found', { code: 404 });
38
- }
21
+ switch (settings.action) {
22
+ case 'refresh':
23
+ return processRefresh({ assistant, Manager, user, settings, libraries });
39
24
 
40
- userData = doc.data();
25
+ case 'tokenize':
26
+ default:
27
+ return processTokenize({ assistant, Manager, admin, settings });
41
28
  }
29
+ };
42
30
 
43
- // Validate provider
44
- if (!settings.provider) {
45
- return assistant.respond('The provider parameter is required.', { code: 400 });
31
+ // ============================================================================
32
+ // Handlers
33
+ // ============================================================================
34
+
35
+ async function processTokenize({ assistant, Manager, admin, settings }) {
36
+ assistant.log('processTokenize settings', {
37
+ hasCode: !!settings.code,
38
+ codeType: typeof settings.code,
39
+ codeLength: settings.code?.length,
40
+ hasEncryptedState: !!settings.encryptedState,
41
+ encryptedStateLength: settings.encryptedState?.length,
42
+ });
43
+
44
+ // Validate required params
45
+ if (!settings.code) {
46
+ return assistant.respond('Missing authorization code', { code: 400 });
46
47
  }
47
48
 
48
- // Load provider module
49
- let oauth2;
50
-
51
- try {
52
- oauth2 = require(`./providers/${settings.provider}.js`);
53
- } catch (e) {
54
- return assistant.respond(`Unknown OAuth2 provider: ${settings.provider}`, { code: 400 });
49
+ if (!settings.encryptedState) {
50
+ return assistant.respond('Missing encrypted state', { code: 400 });
55
51
  }
56
52
 
57
- // Build OAuth2 URL for current state
58
- const ultimateJekyllOAuth2Url = assistant.isDevelopment()
53
+ // Build redirect URI
54
+ const redirectUri = assistant.isDevelopment()
59
55
  ? 'https://localhost:4000/oauth2'
60
56
  : `${Manager.config.brand.url}/oauth2`;
61
57
 
62
- // Get OAuth2 credentials from environment variables
63
- // Format: OAUTH2_{PROVIDER}_CLIENT_ID, OAUTH2_{PROVIDER}_CLIENT_SECRET
64
- const providerEnvKey = settings.provider.toUpperCase().replace(/-/g, '_');
65
- const client_id = process.env[`OAUTH2_${providerEnvKey}_CLIENT_ID`];
66
- const client_secret = process.env[`OAUTH2_${providerEnvKey}_CLIENT_SECRET`];
67
-
68
- const state = settings.state;
69
-
70
- assistant.log('OAuth2 settings', settings);
71
-
72
- // Process by state
73
- switch (state) {
74
- case 'authorize':
75
- return processAuthorize(assistant, Manager, settings, oauth2, ultimateJekyllOAuth2Url, client_id);
76
-
77
- case 'tokenize':
78
- return processTokenize(assistant, Manager, admin, settings, oauth2, ultimateJekyllOAuth2Url, client_id, client_secret, uid);
58
+ // Decrypt and validate state
59
+ let stateData;
79
60
 
80
- case 'refresh':
81
- return processRefresh(assistant, Manager, admin, settings, oauth2, client_id, client_secret, uid, userData);
82
-
83
- case 'deauthorize':
84
- return processDeauthorize(assistant, Manager, admin, settings, uid);
61
+ try {
62
+ stateData = decryptState(settings.encryptedState);
63
+ } catch (e) {
64
+ assistant.log('Failed to decrypt state:', e.message);
65
+ return assistant.respond('Invalid OAuth state', { code: 400 });
66
+ }
85
67
 
86
- case 'status':
87
- return processStatus(assistant, Manager, admin, settings, oauth2, uid, userData);
68
+ // Validate timestamp (10 min TTL)
69
+ const ageMinutes = (Date.now() - stateData.ts) / 1000 / 60;
88
70
 
89
- default:
90
- return assistant.respond(`Unknown OAuth2 state: ${state}`, { code: 400 });
71
+ if (ageMinutes > STATE_TTL_MINUTES) {
72
+ return assistant.respond('OAuth session expired. Please try again.', { code: 400 });
91
73
  }
92
- };
93
74
 
94
- async function processAuthorize(assistant, Manager, settings, oauth2, ultimateJekyllOAuth2Url, client_id) {
95
- if (!client_id) {
96
- return assistant.respond(`Missing client_id for ${settings.provider} provider`, { code: 500 });
75
+ // Load provider from decrypted state
76
+ const providerResult = loadProvider(stateData.provider);
77
+
78
+ if (providerResult.error) {
79
+ return assistant.respond(providerResult.error.message, { code: providerResult.error.code });
97
80
  }
98
81
 
99
- // Build state data - some defaults require runtime context so we keep fallbacks here
100
- const defaultReferrer = assistant.isDevelopment() ? 'https://localhost:4000/account' : `${Manager.config.brand.url}/account`;
101
- const stateData = {
102
- code: 'success',
103
- provider: settings.provider,
104
- authenticationToken: settings.authenticationToken,
105
- serverUrl: settings.serverUrl || `${Manager.project.apiUrl}/backend-manager`,
106
- referrer: settings.referrer || defaultReferrer,
107
- redirectUrl: settings.redirect_uri || settings.referrer || defaultReferrer,
108
- };
82
+ const { oauth2Provider, clientId, clientSecret } = providerResult;
109
83
 
110
- const url = new URL(oauth2.urls.authorize);
111
- url.searchParams.set('state', JSON.stringify(stateData));
112
- url.searchParams.set('client_id', client_id);
113
- url.searchParams.set('scope', arrayify(settings.scope).join(' '));
114
- url.searchParams.set('redirect_uri', ultimateJekyllOAuth2Url);
115
- url.searchParams.set('access_type', settings.access_type);
116
- url.searchParams.set('prompt', settings.prompt);
117
- url.searchParams.set('include_granted_scopes', settings.include_granted_scopes);
118
- url.searchParams.set('response_type', settings.response_type);
84
+ // Retrieve stored CSRF token from user's usage document
85
+ const usageDocRef = admin.firestore().doc(`usage/${stateData.uid}`);
86
+ const usageDoc = await usageDocRef.get();
119
87
 
120
- // Allow provider to modify URL
121
- const finalUrl = oauth2.buildUrl('authorize', url, assistant);
122
- const urlString = (finalUrl || url).toString();
88
+ if (!usageDoc.exists) {
89
+ return assistant.respond('OAuth session not found. Please try again.', { code: 400 });
90
+ }
123
91
 
124
- assistant.log('OAuth2 authorize URL', urlString);
92
+ const storedCsrf = usageDoc.data()?.oauth2?.[stateData.provider]?.csrf;
125
93
 
126
- if (settings.redirect) {
127
- return assistant.redirect(urlString);
94
+ if (!storedCsrf) {
95
+ return assistant.respond('OAuth session not found. Please try again.', { code: 400 });
128
96
  }
129
97
 
130
- return assistant.respond({ url: urlString });
131
- }
132
-
133
- async function processTokenize(assistant, Manager, admin, settings, oauth2, ultimateJekyllOAuth2Url, client_id, client_secret, uid) {
134
- assistant.log('Running processTokenize()');
98
+ // Validate CSRF token
99
+ if (storedCsrf !== stateData.csrf) {
100
+ assistant.log('CSRF mismatch', { stored: storedCsrf, received: stateData.csrf });
101
+ return assistant.respond('Invalid OAuth session', { code: 400 });
102
+ }
135
103
 
104
+ // Exchange code for tokens
136
105
  const body = {
137
- client_id,
138
- client_secret,
106
+ client_id: clientId,
107
+ client_secret: clientSecret,
139
108
  grant_type: 'authorization_code',
140
- redirect_uri: ultimateJekyllOAuth2Url,
109
+ redirect_uri: redirectUri,
141
110
  code: settings.code,
142
111
  };
143
112
 
144
- assistant.log('tokenize body', body);
145
-
146
- const tokenizeResponse = await fetch(oauth2.urls.tokenize, {
113
+ const tokenResponse = await fetch(oauth2Provider.urls.tokenize, {
147
114
  method: 'POST',
148
115
  timeout: 60000,
149
116
  response: 'json',
150
- tries: 1,
151
- log: true,
152
117
  body: new URLSearchParams(body),
153
- cacheBreaker: false,
154
- headers: {
155
- 'Content-Type': 'application/x-www-form-urlencoded',
156
- },
157
- }).catch((e) => e);
118
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
119
+ }).catch(e => e);
158
120
 
159
- assistant.log('tokenizeResponse', tokenizeResponse);
160
-
161
- if (tokenizeResponse instanceof Error) {
162
- return assistant.respond(tokenizeResponse.message, { code: 500 });
121
+ if (tokenResponse instanceof Error) {
122
+ return assistant.respond(`Token exchange failed: ${tokenResponse.message}`, { code: 500 });
163
123
  }
164
124
 
165
- // Verify identity
166
- const verifiedIdentity = await oauth2.verifyIdentity(tokenizeResponse, Manager, assistant)
167
- .catch((e) => e);
168
-
169
- assistant.log('verifiedIdentity', verifiedIdentity);
125
+ // Verify identity with provider
126
+ const verifiedIdentity = await oauth2Provider.verifyIdentity(tokenResponse, Manager, assistant)
127
+ .catch(e => e);
170
128
 
171
129
  if (verifiedIdentity instanceof Error) {
172
130
  return assistant.respond(verifiedIdentity.message, { code: 400 });
173
131
  }
174
132
 
175
- if (tokenizeResponse && !tokenizeResponse.refresh_token) {
133
+ if (!tokenResponse.refresh_token) {
176
134
  return assistant.respond(
177
- `Missing "refresh_token" in response. Visit ${oauth2.urls.removeAccess} and remove our app from your account and then try again.`,
135
+ `Missing refresh_token. Visit ${oauth2Provider.urls.removeAccess} and remove our app, then try again.`,
178
136
  { code: 400 }
179
137
  );
180
138
  }
181
139
 
182
- // Store tokens
183
- await admin.firestore().doc(`users/${uid}`)
184
- .set({
185
- oauth2: {
186
- [settings.provider]: {
187
- code: _.omit(settings, ['redirect', 'referrer', 'provider', 'state']),
188
- token: tokenizeResponse,
189
- identity: verifiedIdentity,
190
- updated: {
191
- timestamp: assistant.meta.startTime.timestamp,
192
- timestampUNIX: assistant.meta.startTime.timestampUNIX,
193
- },
140
+ // Store tokens (only necessary fields, no raw settings)
141
+ await admin.firestore().doc(`users/${stateData.uid}`).set({
142
+ oauth2: {
143
+ [stateData.provider]: {
144
+ token: {
145
+ access_token: tokenResponse.access_token,
146
+ refresh_token: tokenResponse.refresh_token,
147
+ token_type: tokenResponse.token_type,
148
+ expires_in: tokenResponse.expires_in,
149
+ scope: tokenResponse.scope,
150
+ },
151
+ identity: verifiedIdentity,
152
+ updated: {
153
+ timestamp: assistant.meta.startTime.timestamp,
154
+ timestampUNIX: assistant.meta.startTime.timestampUNIX,
194
155
  },
195
156
  },
196
- metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
197
- }, { merge: true })
198
- .catch((e) => {
199
- return assistant.respond(`Failed to store tokens: ${e.message}`, { code: 500 });
200
- });
157
+ },
158
+ metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
159
+ }, { merge: true });
160
+
161
+ // Delete CSRF token (cleanup)
162
+ await usageDocRef.update({
163
+ [`oauth2.${stateData.provider}`]: admin.firestore.FieldValue.delete(),
164
+ });
165
+
166
+ assistant.log('OAuth2 tokenize complete');
201
167
 
202
168
  return assistant.respond({ success: true });
203
169
  }
204
170
 
205
- async function processRefresh(assistant, Manager, admin, settings, oauth2, client_id, client_secret, uid, userData) {
206
- assistant.log('Running processRefresh()');
171
+ async function processRefresh({ assistant, Manager, user, settings, libraries }) {
172
+ const context = await buildContext({ assistant, Manager, user, settings, libraries });
173
+
174
+ if (context.error) {
175
+ return assistant.respond(context.error.message, { code: context.error.code });
176
+ }
177
+
178
+ const { admin, oauth2Provider, targetUid, targetUser, clientId, clientSecret } = context;
207
179
 
208
- const refresh_token = _.get(userData, `oauth2.${settings.provider}.token.refresh_token`);
180
+ const refreshToken = targetUser?.oauth2?.[settings.provider]?.token?.refresh_token;
209
181
 
210
- if (!refresh_token) {
182
+ if (!refreshToken) {
211
183
  return assistant.respond('No refresh token found', { code: 400 });
212
184
  }
213
185
 
214
186
  const body = {
215
- client_id,
216
- client_secret,
187
+ client_id: clientId,
188
+ client_secret: clientSecret,
217
189
  grant_type: 'refresh_token',
218
- refresh_token,
190
+ refresh_token: refreshToken,
219
191
  };
220
192
 
221
- assistant.log('refresh body', body);
222
-
223
- const refreshResponse = await fetch(oauth2.urls.refresh, {
193
+ const refreshResponse = await fetch(oauth2Provider.urls.refresh, {
224
194
  method: 'POST',
225
195
  timeout: 60000,
226
196
  response: 'json',
227
- tries: 1,
228
- log: true,
229
197
  body: new URLSearchParams(body),
230
- cacheBreaker: false,
231
- headers: {
232
- 'Content-Type': 'application/x-www-form-urlencoded',
233
- },
234
- }).catch((e) => e);
235
-
236
- assistant.log('refreshResponse', refreshResponse);
198
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
199
+ }).catch(e => e);
237
200
 
238
201
  if (refreshResponse instanceof Error) {
239
- return assistant.respond(refreshResponse.message, { code: 500 });
202
+ return assistant.respond(`Token refresh failed: ${refreshResponse.message}`, { code: 500 });
240
203
  }
241
204
 
242
- // Store refreshed tokens
243
- await admin.firestore().doc(`users/${uid}`)
244
- .set({
245
- oauth2: {
246
- [settings.provider]: {
247
- token: refreshResponse,
248
- updated: {
249
- timestamp: assistant.meta.startTime.timestamp,
250
- timestampUNIX: assistant.meta.startTime.timestampUNIX,
251
- },
205
+ // Update stored tokens
206
+ await admin.firestore().doc(`users/${targetUid}`).set({
207
+ oauth2: {
208
+ [settings.provider]: {
209
+ token: {
210
+ access_token: refreshResponse.access_token,
211
+ refresh_token: refreshResponse.refresh_token || refreshToken, // Some providers don't return new refresh token
212
+ token_type: refreshResponse.token_type,
213
+ expires_in: refreshResponse.expires_in,
214
+ scope: refreshResponse.scope,
252
215
  },
253
- },
254
- metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
255
- }, { merge: true })
256
- .catch((e) => {
257
- return assistant.respond(`Failed to store tokens: ${e.message}`, { code: 500 });
258
- });
259
-
260
- return assistant.respond({ success: true });
261
- }
262
-
263
- async function processDeauthorize(assistant, Manager, admin, settings, uid) {
264
- await admin.firestore().doc(`users/${uid}`)
265
- .set({
266
- oauth2: {
267
- [settings.provider]: {},
268
216
  updated: {
269
217
  timestamp: assistant.meta.startTime.timestamp,
270
218
  timestampUNIX: assistant.meta.startTime.timestampUNIX,
271
219
  },
272
220
  },
273
- metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
274
- }, { merge: true })
275
- .catch((e) => {
276
- return assistant.respond(`Failed to deauthorize: ${e.message}`, { code: 500 });
277
- });
221
+ },
222
+ metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
223
+ }, { merge: true });
278
224
 
279
225
  return assistant.respond({ success: true });
280
226
  }
281
-
282
- async function processStatus(assistant, Manager, admin, settings, oauth2, uid, userData) {
283
- const removeInvalidTokens = settings.removeInvalidTokens;
284
-
285
- const token = _.get(userData, `oauth2.${settings.provider}.token.refresh_token`, '');
286
-
287
- if (!token) {
288
- return assistant.respond({ status: 'disconnected' });
289
- }
290
-
291
- // If provider has verifyConnection, use it
292
- if (oauth2.verifyConnection) {
293
- const status = await oauth2.verifyConnection(token, Manager, assistant)
294
- .catch(async (e) => {
295
- if (removeInvalidTokens) {
296
- await removeOAuth2Token(admin, settings.provider, uid, assistant, Manager);
297
- }
298
- return 'error';
299
- });
300
-
301
- if (status === 'disconnected' && removeInvalidTokens) {
302
- await removeOAuth2Token(admin, settings.provider, uid, assistant, Manager);
303
- }
304
-
305
- return assistant.respond({ status });
306
- }
307
-
308
- // Default to connected if we have a token
309
- return assistant.respond({ status: 'connected' });
310
- }
311
-
312
- async function removeOAuth2Token(admin, provider, uid, assistant, Manager) {
313
- await admin.firestore().doc(`users/${uid}`)
314
- .set({
315
- oauth2: {
316
- [provider]: {},
317
- updated: {
318
- timestamp: assistant.meta.startTime.timestamp,
319
- timestampUNIX: assistant.meta.startTime.timestampUNIX,
320
- },
321
- },
322
- metadata: Manager.Metadata().set({ tag: 'user/oauth2' }),
323
- }, { merge: true });
324
-
325
- assistant.log(`Removed disconnected token for user: ${uid}`);
326
- }
@@ -7,13 +7,36 @@ module.exports = {
7
7
  authorize: 'https://discord.com/api/oauth2/authorize',
8
8
  tokenize: 'https://discord.com/api/oauth2/token',
9
9
  refresh: 'https://discord.com/api/oauth2/token',
10
+ revoke: 'https://discord.com/api/oauth2/token/revoke',
10
11
  status: '',
11
12
  removeAccess: 'https://discord.com/channels/@me',
12
13
  },
14
+ scope: ['identify', 'email'],
13
15
 
14
- buildUrl(state, url, assistant) {
15
- // Additional URL building if needed for authorize state
16
- return url;
16
+ // Discord doesn't need special auth params
17
+ authParams: {},
18
+
19
+ // Revoke a token with Discord
20
+ async revokeToken(token, context) {
21
+ const { assistant, clientId, clientSecret } = context;
22
+
23
+ const response = await fetch(this.urls.revoke, {
24
+ method: 'POST',
25
+ timeout: 30000,
26
+ body: new URLSearchParams({
27
+ token,
28
+ client_id: clientId,
29
+ client_secret: clientSecret,
30
+ }),
31
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
32
+ }).catch(e => e);
33
+
34
+ if (response instanceof Error) {
35
+ assistant.log('Discord revokeToken error:', response.message);
36
+ return { revoked: false, reason: response.message };
37
+ }
38
+
39
+ return { revoked: true };
17
40
  },
18
41
 
19
42
  async verifyIdentity(tokenizeResult, Manager, assistant) {
@@ -8,13 +8,36 @@ module.exports = {
8
8
  authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
9
9
  tokenize: 'https://oauth2.googleapis.com/token',
10
10
  refresh: 'https://oauth2.googleapis.com/token',
11
+ revoke: 'https://oauth2.googleapis.com/revoke',
11
12
  status: 'https://oauth2.googleapis.com/tokeninfo',
12
13
  removeAccess: 'https://myaccount.google.com/security',
13
14
  },
15
+ scope: ['openid', 'email', 'profile'],
14
16
 
15
- buildUrl(state, url, assistant) {
16
- // Additional URL building if needed for authorize state
17
- return url;
17
+ // Google-specific OAuth parameters
18
+ authParams: {
19
+ access_type: 'offline',
20
+ prompt: 'consent',
21
+ include_granted_scopes: 'true',
22
+ },
23
+
24
+ // Revoke a token with Google
25
+ async revokeToken(token, context) {
26
+ const { assistant } = context;
27
+
28
+ const response = await fetch(this.urls.revoke, {
29
+ method: 'POST',
30
+ timeout: 30000,
31
+ body: new URLSearchParams({ token }),
32
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
33
+ }).catch(e => e);
34
+
35
+ if (response instanceof Error) {
36
+ assistant.log('Google revokeToken error:', response.message);
37
+ return { revoked: false, reason: response.message };
38
+ }
39
+
40
+ return { revoked: true };
18
41
  },
19
42
 
20
43
  async verifyIdentity(tokenizeResult, Manager, assistant) {
@@ -24,6 +47,11 @@ module.exports = {
24
47
  const decoded = jwtDecode(tokenizeResult.id_token);
25
48
  assistant.log('verifyIdentity(): decoded', decoded);
26
49
 
50
+ // Require email scope for proper identity verification
51
+ if (!decoded.email) {
52
+ throw new Error('Email scope is required. Please ensure "email" scope is included in the OAuth request.');
53
+ }
54
+
27
55
  // Check if exists
28
56
  const snap = await Manager.libraries.admin.firestore().collection('users')
29
57
  .where('oauth2.google.identity.email', '==', decoded.email)
@@ -32,10 +32,11 @@ module.exports = async ({ assistant, user, settings, libraries }) => {
32
32
  count += await signOutOfSession(admin, assistant, uid, 'gatherings/online');
33
33
 
34
34
  // Revoke Firebase refresh tokens
35
- await admin.auth().revokeRefreshTokens(uid)
36
- .catch((e) => {
37
- return assistant.respond(`Failed to sign out of all sessions: ${e}`, { code: 500 });
38
- });
35
+ try {
36
+ await admin.auth().revokeRefreshTokens(uid);
37
+ } catch (e) {
38
+ return assistant.respond(`Failed to sign out of all sessions: ${e}`, { code: 500 });
39
+ }
39
40
 
40
41
  return assistant.respond({
41
42
  sessions: count,
@@ -0,0 +1,11 @@
1
+ module.exports = ({ user }) => ({
2
+ uid: {
3
+ types: ['string'],
4
+ default: user?.auth?.uid,
5
+ required: false,
6
+ },
7
+ provider: {
8
+ types: ['string'],
9
+ required: true,
10
+ },
11
+ });
@@ -0,0 +1,27 @@
1
+ module.exports = ({ user }) => ({
2
+ uid: {
3
+ types: ['string'],
4
+ default: user?.auth?.uid,
5
+ required: false,
6
+ },
7
+ provider: {
8
+ types: ['string'],
9
+ required: true,
10
+ },
11
+ action: {
12
+ types: ['string'],
13
+ default: 'authorize',
14
+ enum: ['authorize', 'status'],
15
+ required: false,
16
+ },
17
+ redirect: {
18
+ types: ['boolean'],
19
+ default: true,
20
+ required: false,
21
+ },
22
+ removeInvalidTokens: {
23
+ types: ['boolean'],
24
+ default: true,
25
+ required: false,
26
+ },
27
+ });
@@ -6,72 +6,20 @@ module.exports = ({ user }) => ({
6
6
  },
7
7
  provider: {
8
8
  types: ['string'],
9
- default: undefined,
10
- required: true,
9
+ required: false, // Not required for tokenize (provider comes from encrypted state)
11
10
  },
12
- state: {
11
+ action: {
13
12
  types: ['string'],
14
- default: 'authorize',
15
- required: false,
16
- },
17
- redirect: {
18
- types: ['boolean'],
19
- default: true,
20
- required: false,
21
- },
22
- referrer: {
23
- types: ['string'],
24
- default: undefined,
25
- required: false,
26
- },
27
- serverUrl: {
28
- types: ['string'],
29
- default: undefined,
30
- required: false,
31
- },
32
- redirect_uri: {
33
- types: ['string'],
34
- default: undefined,
35
- required: false,
36
- },
37
- scope: {
38
- types: ['array', 'string'],
39
- default: [],
13
+ default: 'tokenize',
14
+ enum: ['tokenize', 'refresh'],
40
15
  required: false,
41
16
  },
42
17
  code: {
43
18
  types: ['string'],
44
- default: undefined,
45
- required: false,
46
- },
47
- access_type: {
48
- types: ['string'],
49
- default: 'offline',
50
- required: false,
51
- },
52
- prompt: {
53
- types: ['string'],
54
- default: 'consent',
55
- required: false,
56
- },
57
- include_granted_scopes: {
58
- types: ['string', 'boolean'],
59
- default: 'true',
60
- required: false,
61
- },
62
- response_type: {
63
- types: ['string'],
64
- default: 'code',
65
- required: false,
19
+ required: false, // Required for tokenize
66
20
  },
67
- removeInvalidTokens: {
68
- types: ['boolean'],
69
- default: true,
70
- required: false,
71
- },
72
- authenticationToken: {
21
+ encryptedState: {
73
22
  types: ['string'],
74
- default: undefined,
75
- required: false,
23
+ required: false, // Required for tokenize
76
24
  },
77
25
  });
@@ -42,7 +42,7 @@ node_modules/
42
42
  .yarn-integrity
43
43
 
44
44
  # dotenv environment variables file
45
- .env
45
+ .env*
46
46
 
47
47
  # BEM specific
48
48
  .runtimeconfig.json