@app-connect/core 1.7.26 → 1.7.29

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/handlers/auth.js CHANGED
@@ -7,6 +7,7 @@ const adminCore = require('./admin');
7
7
  const { Connector } = require('../models/dynamo/connectorSchema');
8
8
  const { handleDatabaseError } = require('../lib/errorHandler');
9
9
  const managedAuthCore = require('./managedAuth');
10
+ const managedOAuthCore = require('./managedOAuth');
10
11
  const { getHashValue } = require('../lib/util');
11
12
 
12
13
  async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcExtensionId, isFromMCP = false }) {
@@ -20,7 +21,21 @@ async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcEx
20
21
  if (proxyId) {
21
22
  proxyConfig = await Connector.getProxyConfig(proxyId);
22
23
  }
23
- const oauthInfo = await platformModule.getOauthInfo({ tokenUrl, hostname, rcAccountId: query.rcAccountId, proxyId, proxyConfig, userEmail, isFromMCP });
24
+ let managedOAuthSource = null;
25
+ let oauthInfo = null;
26
+ if (query.rcAccountId) {
27
+ const managedOAuthResult = await managedOAuthCore.resolveManagedOAuthInfo({
28
+ rcAccountId: query.rcAccountId,
29
+ platform
30
+ });
31
+ managedOAuthSource = managedOAuthResult.source;
32
+ oauthInfo = managedOAuthResult.oauthInfo;
33
+ }
34
+ if (!oauthInfo) {
35
+ oauthInfo = await platformModule.getOauthInfo({ tokenUrl, hostname, rcAccountId: query.rcAccountId, proxyId, proxyConfig, userEmail, isFromMCP });
36
+ }
37
+ const resolvedHostname = oauthInfo?.hostname ?? hostname;
38
+ const resolvedTokenUrl = oauthInfo?.accessTokenUri ?? tokenUrl;
24
39
  if (oauthInfo.failMessage) {
25
40
  return {
26
41
  userInfo: null,
@@ -40,7 +55,7 @@ async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcEx
40
55
  const oauthApp = oauth.getOAuthApp(oauthInfo);
41
56
  const { accessToken, refreshToken, expires, data } = await oauthApp.code.getToken(callbackUri, overridingOAuthOption);
42
57
  const authHeader = `Bearer ${accessToken}`;
43
- const { successful, platformUserInfo, returnMessage } = await platformModule.getUserInfo({ authHeader, tokenUrl, apiUrl, hostname, platform, username, callbackUri, query, proxyId, proxyConfig, userEmail, data });
58
+ const { successful, platformUserInfo, returnMessage } = await platformModule.getUserInfo({ authHeader, tokenUrl: resolvedTokenUrl, apiUrl, hostname: resolvedHostname, platform, username, callbackUri, query, proxyId, proxyConfig, userEmail, data });
44
59
 
45
60
  if (successful) {
46
61
  let userInfo = null;
@@ -48,10 +63,10 @@ async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcEx
48
63
  userInfo = await saveUserInfo({
49
64
  platformUserInfo,
50
65
  platform,
51
- tokenUrl,
66
+ tokenUrl: resolvedTokenUrl,
52
67
  apiUrl,
53
68
  username,
54
- hostname: platformUserInfo?.overridingHostname ? platformUserInfo.overridingHostname : hostname,
69
+ hostname: platformUserInfo?.overridingHostname ? platformUserInfo.overridingHostname : resolvedHostname,
55
70
  accessToken,
56
71
  refreshToken,
57
72
  tokenExpiry: isNaN(expires) ? null : expires,
@@ -66,6 +81,12 @@ async function onOAuthCallback({ platform, hostname, tokenUrl, query, hashedRcEx
66
81
  if (platformModule.postSaveUserInfo) {
67
82
  userInfo = await platformModule.postSaveUserInfo({ userInfo, oauthApp });
68
83
  }
84
+ if (managedOAuthSource === 'pending') {
85
+ await managedOAuthCore.migratePendingManagedOAuth({
86
+ rcAccountId: query.rcAccountId,
87
+ platform
88
+ });
89
+ }
69
90
  return {
70
91
  userInfo,
71
92
  returnMessage
@@ -249,7 +270,12 @@ async function authValidation({ platform, userId }) {
249
270
  if (existingUser) {
250
271
  const platformModule = connectorRegistry.getConnector(platform);
251
272
  const proxyId = existingUser?.platformAdditionalInfo?.proxyId;
252
- const oauthApp = oauth.getOAuthApp((await platformModule.getOauthInfo({ tokenUrl: existingUser?.platformAdditionalInfo?.tokenUrl, hostname: existingUser?.hostname, proxyId })));
273
+ const managedOAuthResult = await managedOAuthCore.resolveManagedOAuthInfo({
274
+ rcAccountId: existingUser.rcAccountId,
275
+ platform
276
+ });
277
+ const oauthInfo = managedOAuthResult.oauthInfo ?? await platformModule.getOauthInfo({ tokenUrl: existingUser?.platformAdditionalInfo?.tokenUrl, hostname: existingUser?.hostname, proxyId });
278
+ const oauthApp = oauth.getOAuthApp(oauthInfo);
253
279
  existingUser = await oauth.checkAndRefreshAccessToken(oauthApp, existingUser);
254
280
  const { successful, returnMessage, status } = await platformModule.authValidation({ user: existingUser });
255
281
  return {
package/handlers/log.js CHANGED
@@ -184,7 +184,7 @@ async function createCallLog({ platform, userId, incomingData, hashedAccountId,
184
184
  }
185
185
  }
186
186
  )
187
- const syncedPluginJwtToken = syncPluginTokenResponse?.data?.jwtToken ?? pluginJwtToken;
187
+ const syncedPluginJwtToken = pluginCore.getRefreshedJwtTokenFromHeaders({ headers: syncPluginTokenResponse.headers });
188
188
  axios.post(pluginEndpointUrl, {
189
189
  data: incomingData,
190
190
  config: userConfig,
@@ -304,6 +304,12 @@ async function createCallLog({ platform, userId, incomingData, hashedAccountId,
304
304
  pluginAsyncTaskIds
305
305
  };
306
306
  }
307
+ else {
308
+ return {
309
+ successful: false,
310
+ returnMessage
311
+ };
312
+ }
307
313
  } catch (e) {
308
314
  return handleApiError(e, platform, 'createCallLog', { userId });
309
315
  }
@@ -0,0 +1,247 @@
1
+ const { AccountDataModel } = require('../models/accountDataModel');
2
+ const { CacheModel } = require('../models/cacheModel');
3
+ const { encode, decoded } = require('../lib/encode');
4
+
5
+ const MANAGED_OAUTH_ACCOUNT_DATA_KEY = 'managed-oauth-account';
6
+ const MANAGED_OAUTH_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
7
+ const MANAGED_OAUTH_FIELDS = [
8
+ 'clientId',
9
+ 'clientSecret',
10
+ 'accessTokenUri',
11
+ 'authorizationUri',
12
+ 'redirectUri',
13
+ 'scopes',
14
+ 'hostname'
15
+ ];
16
+
17
+ function getPendingManagedOAuthCacheId({ rcAccountId }) {
18
+ return `${rcAccountId}-${MANAGED_OAUTH_ACCOUNT_DATA_KEY}`;
19
+ }
20
+
21
+ function isFilled(value) {
22
+ return value !== undefined && value !== null && value !== '';
23
+ }
24
+
25
+ function encryptStoredValue(value) {
26
+ return {
27
+ version: 1,
28
+ encrypted: true,
29
+ value: encode(JSON.stringify(value))
30
+ };
31
+ }
32
+
33
+ function decryptStoredValue(value) {
34
+ if (!value) {
35
+ return undefined;
36
+ }
37
+ if (value?.encrypted && value?.value) {
38
+ return JSON.parse(decoded(value.value));
39
+ }
40
+ return value;
41
+ }
42
+
43
+ function normalizeManagedOAuthValues(values = {}) {
44
+ const normalized = {};
45
+ MANAGED_OAUTH_FIELDS.forEach(field => {
46
+ if (isFilled(values[field])) {
47
+ normalized[field] = values[field];
48
+ }
49
+ });
50
+ return normalized;
51
+ }
52
+
53
+ function buildStoredData(values = {}) {
54
+ const normalized = normalizeManagedOAuthValues(values);
55
+ const fields = {};
56
+ Object.keys(normalized).forEach(key => {
57
+ fields[key] = encryptStoredValue(normalized[key]);
58
+ });
59
+ return { fields };
60
+ }
61
+
62
+ function decryptStoredData(data = {}) {
63
+ const fields = data?.fields ?? {};
64
+ const values = {};
65
+ Object.keys(fields).forEach(key => {
66
+ values[key] = decryptStoredValue(fields[key]);
67
+ });
68
+ return values;
69
+ }
70
+
71
+ function removeSecret(values = {}) {
72
+ const sanitized = { ...values };
73
+ delete sanitized.clientSecret;
74
+ return sanitized;
75
+ }
76
+
77
+ async function getAccountManagedOAuthRecord({ rcAccountId, platform }) {
78
+ if (!rcAccountId || !platform) {
79
+ return null;
80
+ }
81
+ return AccountDataModel.findOne({
82
+ where: {
83
+ rcAccountId,
84
+ platformName: platform,
85
+ dataKey: MANAGED_OAUTH_ACCOUNT_DATA_KEY
86
+ }
87
+ });
88
+ }
89
+
90
+ async function getPendingManagedOAuthRecord({ rcAccountId }) {
91
+ if (!rcAccountId) {
92
+ return null;
93
+ }
94
+ const record = await CacheModel.findByPk(getPendingManagedOAuthCacheId({ rcAccountId }));
95
+ if (!record) {
96
+ return null;
97
+ }
98
+ if (record.expiry && new Date(record.expiry).getTime() <= Date.now()) {
99
+ await record.destroy();
100
+ return null;
101
+ }
102
+ return record;
103
+ }
104
+
105
+ async function getAccountManagedOAuthValues({ rcAccountId, platform }) {
106
+ const record = await getAccountManagedOAuthRecord({ rcAccountId, platform });
107
+ return decryptStoredData(record?.data);
108
+ }
109
+
110
+ async function getPendingManagedOAuthValues({ rcAccountId }) {
111
+ const record = await getPendingManagedOAuthRecord({ rcAccountId });
112
+ return decryptStoredData(record?.data);
113
+ }
114
+
115
+ async function getManagedOAuthState({ rcAccountId, platform, isAdmin }) {
116
+ const accountValues = await getAccountManagedOAuthValues({ rcAccountId, platform });
117
+ const hasAccountOAuth = Object.keys(accountValues).length > 0;
118
+ const pendingValues = isAdmin && !hasAccountOAuth
119
+ ? await getPendingManagedOAuthValues({ rcAccountId })
120
+ : {};
121
+ const hasPendingOAuth = Object.keys(pendingValues).length > 0;
122
+
123
+ return {
124
+ isAdmin: !!isAdmin,
125
+ hasAccountOAuth,
126
+ hasPendingOAuth,
127
+ ...(hasAccountOAuth ? { oauthValues: removeSecret(accountValues) } : {}),
128
+ ...(isAdmin && hasPendingOAuth ? { pendingValues } : {})
129
+ };
130
+ }
131
+
132
+ async function upsertPendingManagedOAuth({ rcAccountId, values = {} }) {
133
+ if (!rcAccountId) {
134
+ throw new Error('rcAccountId is required');
135
+ }
136
+ const id = getPendingManagedOAuthCacheId({ rcAccountId });
137
+ const data = buildStoredData(values);
138
+ const expiry = new Date(Date.now() + MANAGED_OAUTH_CACHE_TTL_MS);
139
+ const existing = await CacheModel.findByPk(id);
140
+ if (existing) {
141
+ await existing.update({
142
+ status: 'pending',
143
+ userId: rcAccountId,
144
+ cacheKey: MANAGED_OAUTH_ACCOUNT_DATA_KEY,
145
+ data,
146
+ expiry
147
+ });
148
+ return existing;
149
+ }
150
+ return CacheModel.create({
151
+ id,
152
+ status: 'pending',
153
+ userId: rcAccountId,
154
+ cacheKey: MANAGED_OAUTH_ACCOUNT_DATA_KEY,
155
+ data,
156
+ expiry
157
+ });
158
+ }
159
+
160
+ async function migratePendingManagedOAuth({ rcAccountId, platform }) {
161
+ const pending = await getPendingManagedOAuthRecord({ rcAccountId });
162
+ if (!pending) {
163
+ return false;
164
+ }
165
+ const existing = await getAccountManagedOAuthRecord({ rcAccountId, platform });
166
+ const data = pending.data ?? { fields: {} };
167
+ if (existing) {
168
+ await existing.update({ data });
169
+ }
170
+ else {
171
+ await AccountDataModel.create({
172
+ rcAccountId,
173
+ platformName: platform,
174
+ dataKey: MANAGED_OAUTH_ACCOUNT_DATA_KEY,
175
+ data
176
+ });
177
+ }
178
+ await pending.destroy();
179
+ return true;
180
+ }
181
+
182
+ async function resolveManagedOAuthInfo({ rcAccountId, platform }) {
183
+ const accountValues = await getAccountManagedOAuthValues({ rcAccountId, platform });
184
+ if (Object.keys(accountValues).length > 0) {
185
+ return {
186
+ source: 'account',
187
+ oauthInfo: accountValues
188
+ };
189
+ }
190
+ const pendingValues = await getPendingManagedOAuthValues({ rcAccountId });
191
+ if (Object.keys(pendingValues).length > 0) {
192
+ return {
193
+ source: 'pending',
194
+ oauthInfo: pendingValues
195
+ };
196
+ }
197
+ return {
198
+ source: null,
199
+ oauthInfo: null
200
+ };
201
+ }
202
+
203
+ async function clearPendingManagedOAuth({ rcAccountId }) {
204
+ if (!rcAccountId) {
205
+ return 0;
206
+ }
207
+ return CacheModel.destroy({
208
+ where: {
209
+ id: getPendingManagedOAuthCacheId({ rcAccountId })
210
+ }
211
+ });
212
+ }
213
+
214
+ async function clearAccountManagedOAuth({ rcAccountId, platform }) {
215
+ if (!rcAccountId || !platform) {
216
+ return 0;
217
+ }
218
+ return AccountDataModel.destroy({
219
+ where: {
220
+ rcAccountId,
221
+ platformName: platform,
222
+ dataKey: MANAGED_OAUTH_ACCOUNT_DATA_KEY
223
+ }
224
+ });
225
+ }
226
+
227
+ async function resetManagedOAuth({ rcAccountId, platform }) {
228
+ const deletedAccountCount = await clearAccountManagedOAuth({ rcAccountId, platform });
229
+ const deletedPendingCount = await clearPendingManagedOAuth({ rcAccountId });
230
+ return {
231
+ deletedAccountCount,
232
+ deletedPendingCount
233
+ };
234
+ }
235
+
236
+ exports.MANAGED_OAUTH_ACCOUNT_DATA_KEY = MANAGED_OAUTH_ACCOUNT_DATA_KEY;
237
+ exports.MANAGED_OAUTH_FIELDS = MANAGED_OAUTH_FIELDS;
238
+ exports.getManagedOAuthState = getManagedOAuthState;
239
+ exports.upsertPendingManagedOAuth = upsertPendingManagedOAuth;
240
+ exports.getPendingManagedOAuthValues = getPendingManagedOAuthValues;
241
+ exports.getAccountManagedOAuthValues = getAccountManagedOAuthValues;
242
+ exports.migratePendingManagedOAuth = migratePendingManagedOAuth;
243
+ exports.resolveManagedOAuthInfo = resolveManagedOAuthInfo;
244
+ exports.clearPendingManagedOAuth = clearPendingManagedOAuth;
245
+ exports.clearAccountManagedOAuth = clearAccountManagedOAuth;
246
+ exports.resetManagedOAuth = resetManagedOAuth;
247
+ exports.removeSecret = removeSecret;
package/index.js CHANGED
@@ -3,7 +3,6 @@ const cors = require('cors')
3
3
  const bodyParser = require('body-parser');
4
4
  require('body-parser-xml')(bodyParser);
5
5
  const dynamoose = require('dynamoose');
6
- const Sequelize = require('sequelize');
7
6
  const { DynamoDB } = require('@aws-sdk/client-dynamodb');
8
7
  const axios = require('axios');
9
8
  const { UserModel } = require('./models/userModel');
@@ -36,11 +35,8 @@ const s3ErrorLogReport = require('./lib/s3ErrorLogReport');
36
35
  const pluginCore = require('./handlers/plugin');
37
36
  const { handleDatabaseError } = require('./lib/errorHandler');
38
37
  const { updateAuthSession } = require('./lib/authSession');
39
- const {
40
- migrateCallLogsExtensionNumberSqlite,
41
- sqliteCallLogsPkIncludesExtension,
42
- } = require('./lib/migrateCallLogsSchema');
43
38
  const managedAuthCore = require('./handlers/managedAuth');
39
+ const managedOAuthCore = require('./handlers/managedOAuth');
44
40
 
45
41
  let packageJson = null;
46
42
  try {
@@ -80,83 +76,6 @@ async function initDB() {
80
76
  await CacheModel.sync();
81
77
  await CallDownListModel.sync();
82
78
  await AccountDataModel.sync();
83
-
84
- const queryInterface = CallLogModel.sequelize.getQueryInterface();
85
- const sequelizeInstance = CallLogModel.sequelize;
86
- const dialect = sequelizeInstance.getDialect();
87
- const callLogsColumns = await queryInterface.describeTable('callLogs');
88
- const hasExtensionNumber = Object.keys(callLogsColumns).some(
89
- (col) => col.toLowerCase() === 'extensionnumber',
90
- );
91
- // Sequelize's SQLite addConstraint appends PRIMARY KEY instead of replacing it.
92
- const needsSqliteCallLogsRebuild =
93
- dialect === 'sqlite' &&
94
- (!hasExtensionNumber || !(await sqliteCallLogsPkIncludesExtension(sequelizeInstance)));
95
-
96
- if (needsSqliteCallLogsRebuild) {
97
- const transaction = await sequelizeInstance.transaction();
98
-
99
- try {
100
- await migrateCallLogsExtensionNumberSqlite(sequelizeInstance, transaction);
101
- await transaction.commit();
102
- } catch (err) {
103
- await transaction.rollback();
104
- throw err;
105
- }
106
- } else if (!hasExtensionNumber) {
107
- // sync() fills new Postgres DBs; legacy installs need alter + new PK constraint.
108
- const transaction = await sequelizeInstance.transaction();
109
-
110
- try {
111
- await queryInterface.addColumn('callLogs', 'extensionNumber', {
112
- type: Sequelize.STRING,
113
- defaultValue: '',
114
- allowNull: false,
115
- }, { transaction });
116
-
117
- // Postgres already has a PK on (id, sessionId); addConstraint would try to add a second PK and fail.
118
- if (dialect === 'postgres') {
119
- const relname = CallLogModel.tableName;
120
- const pkRows = await sequelizeInstance.query(
121
- `
122
- SELECT c.conname AS name
123
- FROM pg_constraint c
124
- INNER JOIN pg_class rel ON c.conrelid = rel.oid
125
- INNER JOIN pg_namespace nsp ON rel.relnamespace = nsp.oid
126
- WHERE c.contype = 'p'
127
- AND nsp.nspname = current_schema()
128
- AND rel.relname = :relname
129
- `,
130
- {
131
- replacements: { relname },
132
- transaction,
133
- type: Sequelize.QueryTypes.SELECT,
134
- },
135
- );
136
- const pkName = pkRows[0]?.name;
137
- if (pkName) {
138
- const qTable = queryInterface.queryGenerator.quoteTable(CallLogModel.tableName);
139
- const qName = queryInterface.queryGenerator.quoteIdentifier(pkName);
140
- await sequelizeInstance.query(
141
- `ALTER TABLE ${qTable} DROP CONSTRAINT ${qName}`,
142
- { transaction },
143
- );
144
- }
145
- }
146
-
147
- await queryInterface.addConstraint('callLogs', {
148
- fields: ['id', 'sessionId', 'extensionNumber'],
149
- type: 'primary key',
150
- name: 'callLogs_pkey',
151
- transaction,
152
- });
153
-
154
- await transaction.commit();
155
- } catch (err) {
156
- await transaction.rollback();
157
- throw err;
158
- }
159
- }
160
79
  }
161
80
  }
162
81
 
@@ -518,6 +437,35 @@ function createCoreRouter() {
518
437
  res.status(400).send(tracer ? tracer.wrapResponse({ error: e.message || e }) : { error: e.message || e });
519
438
  }
520
439
  });
440
+ router.get('/oauthManagedAuthState', async function (req, res) {
441
+ const tracer = req.headers['is-debug'] === 'true' ? DebugTracer.fromRequest(req) : null;
442
+ tracer?.trace('oauthManagedAuthState:start', { query: req.query });
443
+ try {
444
+ const platform = req.query.platform;
445
+ const rcAccessToken = req.query.rcAccessToken;
446
+ if (!platform) {
447
+ res.status(400).send(tracer ? tracer.wrapResponse('Missing platform name') : 'Missing platform name');
448
+ return;
449
+ }
450
+ if (!rcAccessToken) {
451
+ res.status(400).send(tracer ? tracer.wrapResponse('Missing RingCentral access token') : 'Missing RingCentral access token');
452
+ return;
453
+ }
454
+ const { rcAccountId } = await adminCore.validateRcUserToken({ rcAccessToken });
455
+ const adminValidation = await adminCore.validateAdminRole({ rcAccessToken });
456
+ const state = await managedOAuthCore.getManagedOAuthState({
457
+ platform,
458
+ rcAccountId,
459
+ isAdmin: !!adminValidation.isValidated
460
+ });
461
+ res.status(200).send(tracer ? tracer.wrapResponse(state) : state);
462
+ }
463
+ catch (e) {
464
+ logger.error('Get managed OAuth state failed', { stack: e.stack });
465
+ tracer?.traceError('oauthManagedAuthState:error', e);
466
+ res.status(400).send(tracer ? tracer.wrapResponse({ error: e.message || e }) : { error: e.message || e });
467
+ }
468
+ });
521
469
  // Obsolete
522
470
  router.get('/serverVersionInfo', (req, res) => {
523
471
  const defaultCrmManifest = connectorRegistry.getManifest('default');
@@ -711,6 +659,75 @@ function createCoreRouter() {
711
659
  res.status(400).send(tracer ? tracer.wrapResponse({ error: e.message || e }) : { error: e.message || e });
712
660
  }
713
661
  });
662
+ router.post('/admin/managedOAuth/cache', async function (req, res) {
663
+ const tracer = req.headers['is-debug'] === 'true' ? DebugTracer.fromRequest(req) : null;
664
+ tracer?.trace('setAdminManagedOAuthCache:start', {
665
+ body: {
666
+ hasValues: !!req.body?.values,
667
+ valueKeys: Object.keys(req.body?.values ?? {}).filter(key => key !== 'clientSecret')
668
+ }
669
+ });
670
+ try {
671
+ const { isValidated, rcAccountId } = await adminCore.validateAdminRole({ rcAccessToken: req.query.rcAccessToken });
672
+ if (!isValidated) {
673
+ res.status(403).send(tracer ? tracer.wrapResponse('Admin validation failed') : 'Admin validation failed');
674
+ return;
675
+ }
676
+ await managedOAuthCore.upsertPendingManagedOAuth({
677
+ rcAccountId: rcAccountId?.toString(),
678
+ values: req.body?.values ?? {}
679
+ });
680
+ res.status(200).send(tracer ? tracer.wrapResponse({ successful: true }) : { successful: true });
681
+ }
682
+ catch (e) {
683
+ logger.error('Set managed OAuth pending cache failed', { stack: e.stack });
684
+ tracer?.traceError('setAdminManagedOAuthCache:error', e);
685
+ res.status(400).send(tracer ? tracer.wrapResponse({ error: e.message || e }) : { error: e.message || e });
686
+ }
687
+ });
688
+ router.delete('/admin/managedOAuth/cache', async function (req, res) {
689
+ const tracer = req.headers['is-debug'] === 'true' ? DebugTracer.fromRequest(req) : null;
690
+ tracer?.trace('deleteAdminManagedOAuthCache:start', {});
691
+ try {
692
+ const { isValidated, rcAccountId } = await adminCore.validateAdminRole({ rcAccessToken: req.query.rcAccessToken });
693
+ if (!isValidated) {
694
+ res.status(403).send(tracer ? tracer.wrapResponse('Admin validation failed') : 'Admin validation failed');
695
+ return;
696
+ }
697
+ await managedOAuthCore.clearPendingManagedOAuth({ rcAccountId: rcAccountId?.toString() });
698
+ res.status(200).send(tracer ? tracer.wrapResponse({ successful: true }) : { successful: true });
699
+ }
700
+ catch (e) {
701
+ logger.error('Delete managed OAuth pending cache failed', { stack: e.stack });
702
+ tracer?.traceError('deleteAdminManagedOAuthCache:error', e);
703
+ res.status(400).send(tracer ? tracer.wrapResponse({ error: e.message || e }) : { error: e.message || e });
704
+ }
705
+ });
706
+ router.delete('/admin/managedOAuth/account', async function (req, res) {
707
+ const tracer = req.headers['is-debug'] === 'true' ? DebugTracer.fromRequest(req) : null;
708
+ tracer?.trace('deleteAdminManagedOAuthAccount:start', { query: req.query });
709
+ try {
710
+ const { isValidated, rcAccountId } = await adminCore.validateAdminRole({ rcAccessToken: req.query.rcAccessToken });
711
+ if (!isValidated) {
712
+ res.status(403).send(tracer ? tracer.wrapResponse('Admin validation failed') : 'Admin validation failed');
713
+ return;
714
+ }
715
+ if (!req.query.platform) {
716
+ res.status(400).send(tracer ? tracer.wrapResponse('Missing platform name') : 'Missing platform name');
717
+ return;
718
+ }
719
+ await managedOAuthCore.resetManagedOAuth({
720
+ rcAccountId: rcAccountId?.toString(),
721
+ platform: req.query.platform
722
+ });
723
+ res.status(200).send(tracer ? tracer.wrapResponse({ successful: true }) : { successful: true });
724
+ }
725
+ catch (e) {
726
+ logger.error('Delete managed OAuth account failed', { stack: e.stack });
727
+ tracer?.traceError('deleteAdminManagedOAuthAccount:error', e);
728
+ res.status(400).send(tracer ? tracer.wrapResponse({ error: e.message || e }) : { error: e.message || e });
729
+ }
730
+ });
714
731
  router.post('/admin/userMapping', async function (req, res) {
715
732
  const requestStartTime = new Date().getTime();
716
733
  const tracer = req.headers['is-debug'] === 'true' ? DebugTracer.fromRequest(req) : null;
@@ -71,7 +71,10 @@ async function execute(args) {
71
71
  }
72
72
  const platformModule = connectorRegistry.getConnector(platform);
73
73
  try {
74
- await platformModule.unAuthorize({ user: userToLogout });
74
+ const logoutResult = await platformModule.unAuthorize({ user: userToLogout });
75
+ if (!logoutResult.successful) {
76
+ throw new Error(logoutResult.returnMessage.message);
77
+ }
75
78
  }
76
79
  catch (error) {
77
80
  console.log(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@app-connect/core",
3
- "version": "1.7.26",
3
+ "version": "1.7.29",
4
4
  "description": "RingCentral App Connect Core",
5
5
  "main": "index.js",
6
6
  "repository": {
package/releaseNotes.json CHANGED
@@ -1,4 +1,20 @@
1
1
  {
2
+ "1.7.29": {
3
+ "global": [
4
+ {
5
+ "type": "New",
6
+ "description": "Conditional rendering of logging form fields by contact type"
7
+ }
8
+ ]
9
+ },
10
+ "1.7.27": {
11
+ "global": [
12
+ {
13
+ "type": "New",
14
+ "description": "Managed OAuth for platforms with dynamic OAuth configs for different accounts"
15
+ }
16
+ ]
17
+ },
2
18
  "1.7.26": {
3
19
  "global": [
4
20
  {
@@ -23,6 +23,7 @@ const { Connector } = require('../../models/dynamo/connectorSchema');
23
23
  const { RingCentral } = require('../../lib/ringcentral');
24
24
  const adminCore = require('../../handlers/admin');
25
25
  const { AccountDataModel } = require('../../models/accountDataModel');
26
+ const { CacheModel } = require('../../models/cacheModel');
26
27
  const { encode } = require('../../lib/encode');
27
28
  const { getHashValue } = require('../../lib/util');
28
29
 
@@ -664,6 +665,11 @@ describe('Auth Handler', () => {
664
665
  oauth.getOAuthApp.mockReturnValue(mockOAuthApp);
665
666
  });
666
667
 
668
+ afterEach(async () => {
669
+ await AccountDataModel.destroy({ where: {} });
670
+ await CacheModel.destroy({ where: {} });
671
+ });
672
+
667
673
  test('should handle successful OAuth callback', async () => {
668
674
  // Arrange
669
675
  const mockUserInfo = {
@@ -892,6 +898,74 @@ describe('Auth Handler', () => {
892
898
  overridingOption
893
899
  );
894
900
  });
901
+
902
+ test('should use pending managed OAuth info and promote it after successful callback', async () => {
903
+ await CacheModel.create({
904
+ id: 'rc-managed-managed-oauth-account',
905
+ status: 'pending',
906
+ userId: 'rc-managed',
907
+ cacheKey: 'managed-oauth-account',
908
+ expiry: new Date(Date.now() + 60000),
909
+ data: {
910
+ fields: {
911
+ clientId: { version: 1, encrypted: true, value: encode(JSON.stringify('managed-client-id')) },
912
+ clientSecret: { version: 1, encrypted: true, value: encode(JSON.stringify('managed-client-secret')) },
913
+ accessTokenUri: { version: 1, encrypted: true, value: encode(JSON.stringify('https://managed.example.com/token')) },
914
+ authorizationUri: { version: 1, encrypted: true, value: encode(JSON.stringify('https://managed.example.com/authorize')) },
915
+ redirectUri: { version: 1, encrypted: true, value: encode(JSON.stringify('https://ringcentral.github.io/ringcentral-embeddable/redirect.html')) },
916
+ hostname: { version: 1, encrypted: true, value: encode(JSON.stringify('managed.example.com')) }
917
+ }
918
+ }
919
+ });
920
+
921
+ const mockConnector = global.testUtils.createMockConnector({
922
+ getOauthInfo: jest.fn(),
923
+ getUserInfo: jest.fn().mockResolvedValue({
924
+ successful: true,
925
+ platformUserInfo: { id: 'managed-oauth-user', name: 'Managed OAuth User' },
926
+ returnMessage: { messageType: 'success', message: 'OK' }
927
+ })
928
+ });
929
+ connectorRegistry.getConnector.mockReturnValue(mockConnector);
930
+ mockOAuthApp.code.getToken.mockResolvedValue({
931
+ accessToken: 'token',
932
+ refreshToken: 'refresh',
933
+ expires: new Date()
934
+ });
935
+
936
+ const result = await authHandler.onOAuthCallback({
937
+ platform: 'testCRM',
938
+ hostname: 'old.example.com',
939
+ tokenUrl: '',
940
+ query: {
941
+ callbackUri: 'https://app.example.com/callback?code=code123',
942
+ rcAccountId: 'rc-managed'
943
+ }
944
+ });
945
+
946
+ expect(result.userInfo.id).toBe('managed-oauth-user');
947
+ expect(mockConnector.getOauthInfo).not.toHaveBeenCalled();
948
+ expect(oauth.getOAuthApp).toHaveBeenCalledWith(expect.objectContaining({
949
+ clientId: 'managed-client-id',
950
+ clientSecret: 'managed-client-secret',
951
+ accessTokenUri: 'https://managed.example.com/token',
952
+ authorizationUri: 'https://managed.example.com/authorize'
953
+ }));
954
+ expect(mockConnector.getUserInfo).toHaveBeenCalledWith(expect.objectContaining({
955
+ hostname: 'managed.example.com',
956
+ tokenUrl: 'https://managed.example.com/token'
957
+ }));
958
+ const promoted = await AccountDataModel.findOne({
959
+ where: {
960
+ rcAccountId: 'rc-managed',
961
+ platformName: 'testCRM',
962
+ dataKey: 'managed-oauth-account'
963
+ }
964
+ });
965
+ expect(promoted).not.toBeNull();
966
+ const pending = await CacheModel.findByPk('rc-managed-managed-oauth-account');
967
+ expect(pending).toBeNull();
968
+ });
895
969
  });
896
970
 
897
971
  describe('getLicenseStatus', () => {
@@ -8,6 +8,12 @@ jest.mock('../../handlers/admin', () => ({
8
8
  jest.mock('../../handlers/managedAuth', () => ({
9
9
  getManagedAuthState: jest.fn(),
10
10
  }));
11
+ jest.mock('../../handlers/managedOAuth', () => ({
12
+ getManagedOAuthState: jest.fn(),
13
+ upsertPendingManagedOAuth: jest.fn(),
14
+ clearPendingManagedOAuth: jest.fn(),
15
+ resetManagedOAuth: jest.fn(),
16
+ }));
11
17
  jest.mock('../../handlers/auth', () => ({
12
18
  onApiKeyLogin: jest.fn(),
13
19
  }));
@@ -18,6 +24,7 @@ jest.mock('../../lib/jwt', () => ({
18
24
 
19
25
  const adminCore = require('../../handlers/admin');
20
26
  const managedAuthCore = require('../../handlers/managedAuth');
27
+ const managedOAuthCore = require('../../handlers/managedOAuth');
21
28
  const authCore = require('../../handlers/auth');
22
29
  const { createCoreRouter } = require('../../index');
23
30
 
@@ -63,6 +70,130 @@ describe('Managed Auth Routes', () => {
63
70
  });
64
71
  });
65
72
 
73
+ describe('GET /oauthManagedAuthState', () => {
74
+ test('should validate rcAccessToken and return managed OAuth state for validated account', async () => {
75
+ adminCore.validateRcUserToken.mockResolvedValue({
76
+ rcAccountId: 'validated-account-id',
77
+ rcExtensionId: 'validated-extension-id',
78
+ });
79
+ adminCore.validateAdminRole.mockResolvedValue({
80
+ isValidated: true,
81
+ rcAccountId: 'validated-account-id',
82
+ });
83
+ managedOAuthCore.getManagedOAuthState.mockResolvedValue({
84
+ isAdmin: true,
85
+ hasAccountOAuth: false,
86
+ hasPendingOAuth: true,
87
+ pendingValues: {
88
+ clientId: 'client-id',
89
+ clientSecret: 'client-secret',
90
+ },
91
+ });
92
+
93
+ const response = await request(app)
94
+ .get('/oauthManagedAuthState')
95
+ .query({
96
+ platform: 'testCRM',
97
+ rcAccessToken: 'valid-rc-token',
98
+ rcAccountId: 'spoofed-account-id',
99
+ });
100
+
101
+ expect(response.status).toBe(200);
102
+ expect(adminCore.validateRcUserToken).toHaveBeenCalledWith({ rcAccessToken: 'valid-rc-token' });
103
+ expect(adminCore.validateAdminRole).toHaveBeenCalledWith({ rcAccessToken: 'valid-rc-token' });
104
+ expect(managedOAuthCore.getManagedOAuthState).toHaveBeenCalledWith({
105
+ platform: 'testCRM',
106
+ rcAccountId: 'validated-account-id',
107
+ isAdmin: true,
108
+ });
109
+ expect(response.body.pendingValues.clientSecret).toBe('client-secret');
110
+ });
111
+ });
112
+
113
+ describe('POST /admin/managedOAuth/cache', () => {
114
+ test('should require admin role and write pending managed OAuth values for validated account', async () => {
115
+ adminCore.validateAdminRole.mockResolvedValue({
116
+ isValidated: true,
117
+ rcAccountId: 'validated-account-id',
118
+ });
119
+ managedOAuthCore.upsertPendingManagedOAuth.mockResolvedValue({});
120
+
121
+ const values = {
122
+ clientId: 'client-id',
123
+ clientSecret: 'client-secret',
124
+ };
125
+
126
+ const response = await request(app)
127
+ .post('/admin/managedOAuth/cache')
128
+ .query({ rcAccessToken: 'valid-rc-token' })
129
+ .send({ values });
130
+
131
+ expect(response.status).toBe(200);
132
+ expect(managedOAuthCore.upsertPendingManagedOAuth).toHaveBeenCalledWith({
133
+ rcAccountId: 'validated-account-id',
134
+ values,
135
+ });
136
+ });
137
+
138
+ test('should reject non-admin users', async () => {
139
+ adminCore.validateAdminRole.mockResolvedValue({
140
+ isValidated: false,
141
+ rcAccountId: 'validated-account-id',
142
+ });
143
+
144
+ const response = await request(app)
145
+ .post('/admin/managedOAuth/cache')
146
+ .query({ rcAccessToken: 'valid-rc-token' })
147
+ .send({ values: { clientId: 'client-id' } });
148
+
149
+ expect(response.status).toBe(403);
150
+ expect(managedOAuthCore.upsertPendingManagedOAuth).not.toHaveBeenCalled();
151
+ });
152
+ });
153
+
154
+ describe('DELETE /admin/managedOAuth/account', () => {
155
+ test('should require admin role and delete managed OAuth account for validated account', async () => {
156
+ adminCore.validateAdminRole.mockResolvedValue({
157
+ isValidated: true,
158
+ rcAccountId: 'validated-account-id',
159
+ });
160
+ managedOAuthCore.resetManagedOAuth.mockResolvedValue({
161
+ deletedAccountCount: 1,
162
+ deletedPendingCount: 1,
163
+ });
164
+
165
+ const response = await request(app)
166
+ .delete('/admin/managedOAuth/account')
167
+ .query({
168
+ rcAccessToken: 'valid-rc-token',
169
+ platform: 'testCRM',
170
+ });
171
+
172
+ expect(response.status).toBe(200);
173
+ expect(managedOAuthCore.resetManagedOAuth).toHaveBeenCalledWith({
174
+ rcAccountId: 'validated-account-id',
175
+ platform: 'testCRM',
176
+ });
177
+ });
178
+
179
+ test('should reject non-admin users', async () => {
180
+ adminCore.validateAdminRole.mockResolvedValue({
181
+ isValidated: false,
182
+ rcAccountId: 'validated-account-id',
183
+ });
184
+
185
+ const response = await request(app)
186
+ .delete('/admin/managedOAuth/account')
187
+ .query({
188
+ rcAccessToken: 'valid-rc-token',
189
+ platform: 'testCRM',
190
+ });
191
+
192
+ expect(response.status).toBe(403);
193
+ expect(managedOAuthCore.resetManagedOAuth).not.toHaveBeenCalled();
194
+ });
195
+ });
196
+
66
197
  describe('POST /apiKeyLogin', () => {
67
198
 
68
199
  test('should validate rcAccessToken and ignore spoofed rc ids in body', async () => {