auth.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. // Zoho OAuth2 Authentication Module
  2. // EU data center: accounts.zoho.eu
  3. var ACCOUNTS_BASE = 'https://accounts.zoho.eu';
  4. var SCOPE = 'ZohoProjects.tasks.ALL,ZohoProjects.projects.READ,ZohoProjects.tasklists.READ,ZohoProjects.timesheets.ALL,ZohoProjects.statuses.READ,ZohoProjects.tasklists.CREATE,ZohoProjects.tasklists.UPDATE,ZohoProjects.portals.READ';
  5. function setupAuth(grantToken) {
  6. var clientId = shadowman.config.value('client_id');
  7. var clientSecret = shadowman.config.value('client_secret');
  8. if (!clientId || !clientSecret) {
  9. return { error: 'Missing OAuth credentials. Set client_id and client_secret in plugin config.' };
  10. }
  11. if (!grantToken) {
  12. return { error: 'Missing grant_token parameter. Generate one from Zoho Developer Console (Self Client).' };
  13. }
  14. shadowman.log.info('Exchanging Zoho grant token for access + refresh tokens...');
  15. var body = 'grant_type=authorization_code' +
  16. '&client_id=' + encodeURIComponent(clientId) +
  17. '&client_secret=' + encodeURIComponent(clientSecret) +
  18. '&redirect_uri=oob' +
  19. '&code=' + encodeURIComponent(grantToken);
  20. var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, {
  21. headers: {
  22. 'Content-Type': 'application/x-www-form-urlencoded'
  23. }
  24. });
  25. if (resp.status !== 200) {
  26. shadowman.log.error('Token exchange failed: ' + resp.status + ' ' + resp.body);
  27. var errBody;
  28. try {
  29. errBody = JSON.parse(resp.body);
  30. } catch (e) {
  31. errBody = resp.body;
  32. }
  33. return { error: 'Token exchange failed: ' + (errBody.error || errBody.error_description || resp.body) };
  34. }
  35. var tokens;
  36. try {
  37. tokens = JSON.parse(resp.body);
  38. } catch (e) {
  39. return { error: 'Invalid response from Zoho: ' + resp.body };
  40. }
  41. shadowman.storage.set('tokens', {
  42. access_token: tokens.access_token,
  43. refresh_token: tokens.refresh_token,
  44. expires_at: Date.now() + (tokens.expires_in * 1000),
  45. api_domain: tokens.api_domain || 'https://projectsapi.zoho.eu',
  46. token_type: tokens.token_type || 'Bearer'
  47. });
  48. shadowman.log.info('Zoho OAuth tokens stored successfully. Refresh token does not expire.');
  49. // Auto-resolve portal if portal_url config is set
  50. var portalUrl = shadowman.config.value('portal_url');
  51. var resolveResult = null;
  52. if (portalUrl) {
  53. resolveResult = resolvePortalInternal(tokens.access_token, portalUrl, null);
  54. }
  55. var result = {
  56. success: true,
  57. message: 'Authentication successful. Refresh token stored permanently.',
  58. expires_in: tokens.expires_in
  59. };
  60. if (resolveResult && resolveResult.success) {
  61. result.portal_id = resolveResult.portal_id;
  62. result.portal_name = resolveResult.portal_name;
  63. result.message += ' Portal auto-resolved: ' + resolveResult.portal_name + ' (ID: ' + resolveResult.portal_id + ')';
  64. } else if (portalUrl) {
  65. result.message += ' Portal auto-resolution failed. Run resolve_portal manually.';
  66. } else {
  67. result.message += ' Run resolve_portal with your portal URL to auto-configure.';
  68. }
  69. // Notify conversation
  70. var convId = shadowman.storage.get('auth_conversation_id');
  71. if (convId) {
  72. var msg = 'Zoho OAuth sikeres!';
  73. if (resolveResult && resolveResult.success) {
  74. msg += ' Portal automatikusan beállítva: ' + resolveResult.portal_name;
  75. } else {
  76. msg += ' Futtasd a resolve_portal parancsot a portal URL-eddel.';
  77. }
  78. shadowman.events.emit('message', {
  79. text: msg,
  80. conversationId: convId
  81. });
  82. shadowman.storage.del('auth_conversation_id');
  83. }
  84. return result;
  85. }
  86. function resolvePortalInternal(token, portalUrl, portalName) {
  87. var apiBase = 'https://projectsapi.zoho.eu';
  88. // Method 1: /api/v3/portals — returns all portals with numeric ID, name, slug
  89. var resp = shadowman.http.request(apiBase + '/api/v3/portals', {
  90. method: 'GET',
  91. headers: {
  92. 'Authorization': 'Zoho-oauthtoken ' + token
  93. }
  94. });
  95. if (resp.status === 200) return processPortalsResponse(resp.body, portalUrl, portalName);
  96. // Method 2: Fallback — use v1 API with slug, extract numeric ID from response
  97. var slug = portalUrl ? extractSlug(portalUrl) : '';
  98. if (!slug) {
  99. var existingId = shadowman.config.value('portal_id');
  100. if (existingId) {
  101. return { success: true, portal_id: existingId, note: 'Using existing portal_id. Re-run setup_auth with ZohoProjects.portals.READ scope to enable /api/v3/portals.' };
  102. }
  103. return { error: 'Portal URL not provided and /api/v3/portals failed (scope?). Run setup_auth again with a fresh grant token.' };
  104. }
  105. var testResp = shadowman.http.request(apiBase + '/restapi/portal/' + slug + '/projects/', {
  106. method: 'GET',
  107. headers: {
  108. 'Authorization': 'Zoho-oauthtoken ' + token
  109. }
  110. });
  111. if (testResp.status === 200) {
  112. try {
  113. var testData = JSON.parse(testResp.body);
  114. var projects = testData.projects || [];
  115. if (projects.length > 0) {
  116. var url = (projects[0].link || {}).self || {};
  117. var urlStr = url.url || '';
  118. var urlMatch = urlStr.match(/\/portal\/(\d+)\//);
  119. if (urlMatch) {
  120. var numericId = urlMatch[1];
  121. shadowman.config.set('portal_id', numericId);
  122. shadowman.log.info('Portal resolved (v1 fallback): ' + slug + ' -> ID: ' + numericId);
  123. return { success: true, portal_id: numericId, portal_slug: slug };
  124. }
  125. }
  126. } catch (e) { /* ignore */ }
  127. shadowman.config.set('portal_id', slug);
  128. shadowman.log.info('Portal configured with slug (no numeric ID found): ' + slug);
  129. return { success: true, portal_id: slug, portal_slug: slug, note: 'Using slug. Timesheet API may require numeric ID.' };
  130. }
  131. return { error: 'Portal "' + slug + '" not found. Check the URL and your access.' };
  132. }
  133. function processPortalsResponse(body, portalUrl, portalName) {
  134. var data;
  135. try {
  136. data = JSON.parse(body);
  137. } catch (e) {
  138. return { error: 'Invalid portal list response: ' + body };
  139. }
  140. var portals = data.portals || [];
  141. if (portals.length === 0) {
  142. return { error: 'No portals found for this account.', portals: [] };
  143. }
  144. if (portals.length === 1 && !portalUrl && !portalName) {
  145. var p = portals[0];
  146. shadowman.config.set('portal_id', String(p.id));
  147. shadowman.log.info('Portal auto-configured: ' + p.name + ' (ID: ' + p.id + ')');
  148. return { success: true, portal_id: String(p.id), portal_name: p.name, portal_slug: p.portal_slug };
  149. }
  150. var match = null;
  151. if (portalUrl) {
  152. var slug = extractSlug(portalUrl);
  153. for (var i = 0; i < portals.length; i++) {
  154. if (portals[i].portal_slug && portals[i].portal_slug.toLowerCase() === slug.toLowerCase()) {
  155. match = portals[i];
  156. break;
  157. }
  158. if (portals[i].name && portals[i].name.toLowerCase() === slug.toLowerCase()) {
  159. match = portals[i];
  160. break;
  161. }
  162. }
  163. } else if (portalName) {
  164. for (var j = 0; j < portals.length; j++) {
  165. if (portals[j].name && portals[j].name.toLowerCase() === portalName.toLowerCase()) {
  166. match = portals[j];
  167. break;
  168. }
  169. }
  170. }
  171. if (match) {
  172. shadowman.config.set('portal_id', String(match.id));
  173. shadowman.log.info('Portal configured: ' + match.name + ' (ID: ' + match.id + ')');
  174. return { success: true, portal_id: String(match.id), portal_name: match.name, portal_slug: match.portal_slug };
  175. }
  176. var list = [];
  177. for (var k = 0; k < portals.length; k++) {
  178. list.push({
  179. id: String(portals[k].id),
  180. name: portals[k].name,
  181. slug: portals[k].portal_slug
  182. });
  183. }
  184. return { error: 'Portal not found. Available portals:', portals: list };
  185. }
  186. function extractSlug(url) {
  187. if (!url) return '';
  188. var slug = url;
  189. var slashIdx = slug.lastIndexOf('/');
  190. if (slashIdx !== -1) slug = slug.substring(slashIdx + 1);
  191. if (slug === 'portal' || slug === '') return '';
  192. return slug;
  193. }
  194. function resolvePortal(token, args) {
  195. return resolvePortalInternal(token, args.portal_url || null, args.portal_name || null);
  196. }
  197. function getStatus() {
  198. var tokens = shadowman.storage.get('tokens');
  199. if (!tokens) {
  200. return { authenticated: false, message: 'Not authenticated. Use setup_auth with a grant token.' };
  201. }
  202. if (Date.now() >= tokens.expires_at) {
  203. // Token expired, try refresh
  204. var newTokens = refreshTokenInternal();
  205. if (newTokens.error) {
  206. return { authenticated: false, message: 'Token expired and refresh failed. Re-run setup_auth.' };
  207. }
  208. return { authenticated: true, message: 'Authenticated (token refreshed)', domain: newTokens.api_domain };
  209. }
  210. var remaining = Math.round((tokens.expires_at - Date.now()) / 1000);
  211. return {
  212. authenticated: true,
  213. message: 'Authenticated',
  214. expires_in_seconds: remaining,
  215. domain: tokens.api_domain
  216. };
  217. }
  218. function getValidToken() {
  219. var tokens = shadowman.storage.get('tokens');
  220. if (!tokens) {
  221. return null;
  222. }
  223. // Check if token needs refresh (5 minute buffer)
  224. if (Date.now() >= (tokens.expires_at - 300000)) {
  225. var newTokens = refreshTokenInternal();
  226. if (newTokens.error) {
  227. shadowman.log.error('Token refresh failed: ' + newTokens.error);
  228. // Refresh failed but token might still be valid
  229. if (Date.now() < tokens.expires_at) {
  230. return tokens.access_token;
  231. }
  232. return null;
  233. }
  234. return newTokens.access_token;
  235. }
  236. return tokens.access_token;
  237. }
  238. function refreshTokenInternal() {
  239. var tokens = shadowman.storage.get('tokens');
  240. if (!tokens || !tokens.refresh_token) {
  241. return { error: 'No refresh token available' };
  242. }
  243. var clientId = shadowman.config.value('client_id');
  244. var clientSecret = shadowman.config.value('client_secret');
  245. if (!clientId || !clientSecret) {
  246. return { error: 'OAuth client credentials missing' };
  247. }
  248. shadowman.log.info('Refreshing Zoho access token...');
  249. var body = 'grant_type=refresh_token' +
  250. '&client_id=' + encodeURIComponent(clientId) +
  251. '&client_secret=' + encodeURIComponent(clientSecret) +
  252. '&refresh_token=' + encodeURIComponent(tokens.refresh_token);
  253. var resp = shadowman.http.post(ACCOUNTS_BASE + '/oauth/v2/token', body, {
  254. headers: {
  255. 'Content-Type': 'application/x-www-form-urlencoded'
  256. }
  257. });
  258. if (resp.status !== 200) {
  259. shadowman.log.error('Token refresh failed: ' + resp.status + ' ' + resp.body);
  260. return { error: 'Refresh failed: ' + resp.body };
  261. }
  262. var newTokens;
  263. try {
  264. newTokens = JSON.parse(resp.body);
  265. } catch (e) {
  266. return { error: 'Invalid refresh response: ' + resp.body };
  267. }
  268. var updated = {
  269. access_token: newTokens.access_token,
  270. refresh_token: tokens.refresh_token, // Keep original - Zoho refresh tokens don't expire
  271. expires_at: Date.now() + (newTokens.expires_in * 1000),
  272. api_domain: newTokens.api_domain || tokens.api_domain,
  273. token_type: newTokens.token_type || 'Bearer'
  274. };
  275. shadowman.storage.set('tokens', updated);
  276. shadowman.log.info('Zoho token refreshed successfully');
  277. return updated;
  278. }
  279. module.exports = {
  280. setupAuth: setupAuth,
  281. getStatus: getStatus,
  282. getValidToken: getValidToken,
  283. resolvePortal: resolvePortal
  284. };