token-manager.js 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. /* global FIREFOX getActiveTab waitForTabUrl URLS */// toolbox.js
  2. /* global chromeLocal */// storage-util.js
  3. 'use strict';
  4. /* exported tokenMan */
  5. const tokenMan = (() => {
  6. const AUTH = {
  7. dropbox: {
  8. flow: 'token',
  9. clientId: 'zg52vphuapvpng9',
  10. authURL: 'https://www.dropbox.com/oauth2/authorize',
  11. tokenURL: 'https://api.dropboxapi.com/oauth2/token',
  12. revoke: token =>
  13. fetch('https://api.dropboxapi.com/2/auth/token/revoke', {
  14. method: 'POST',
  15. headers: {
  16. 'Authorization': `Bearer ${token}`,
  17. },
  18. }),
  19. },
  20. google: {
  21. flow: 'code',
  22. clientId: '283762574871-d4u58s4arra5jdan2gr00heasjlttt1e.apps.googleusercontent.com',
  23. clientSecret: 'J0nc5TlR_0V_ex9-sZk-5faf',
  24. authURL: 'https://accounts.google.com/o/oauth2/v2/auth',
  25. authQuery: {
  26. // NOTE: Google needs 'prompt' parameter to deliver multiple refresh
  27. // tokens for multiple machines.
  28. // https://stackoverflow.com/q/18519185
  29. access_type: 'offline',
  30. prompt: 'consent',
  31. },
  32. tokenURL: 'https://oauth2.googleapis.com/token',
  33. scopes: ['https://www.googleapis.com/auth/drive.appdata'],
  34. // FIXME: https://github.com/openstyles/stylus/issues/1248
  35. // revoke: token => {
  36. // const params = {token};
  37. // return postQuery(`https://accounts.google.com/o/oauth2/revoke?${new URLSearchParams(params)}`);
  38. // },
  39. },
  40. onedrive: {
  41. flow: 'code',
  42. clientId: '3864ce03-867c-4ad8-9856-371a097d47b1',
  43. clientSecret: '9Pj=TpsrStq8K@1BiwB9PIWLppM:@s=w',
  44. authURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
  45. tokenURL: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
  46. redirect_uri: FIREFOX ?
  47. 'https://clngdbkpkpeebahjckkjfobafhncgmne.chromiumapp.org/' :
  48. 'https://' + location.hostname + '.chromiumapp.org/',
  49. scopes: ['Files.ReadWrite.AppFolder', 'offline_access'],
  50. },
  51. userstylesworld: {
  52. flow: 'code',
  53. clientId: 'zeDmKhJIfJqULtcrGMsWaxRtWHEimKgS',
  54. clientSecret: 'wqHsvTuThQmXmDiVvOpZxPwSIbyycNFImpAOTxjaIRqDbsXcTOqrymMJKsOMuibFaij' +
  55. 'ZZAkVYTDbLkQuYFKqgpMsMlFlgwQOYHvHFbgxQHDTwwdOroYhOwFuekCwXUlk',
  56. authURL: URLS.usw + 'api/oauth/style/link',
  57. tokenURL: URLS.usw + 'api/oauth/token',
  58. redirect_uri: 'https://gusted.xyz/callback_helper/',
  59. },
  60. };
  61. const NETWORK_LATENCY = 30; // seconds
  62. let alwaysUseTab = FIREFOX ? false : null;
  63. class TokenError extends Error {
  64. constructor(provider, message) {
  65. super(`[${provider}] ${message}`);
  66. this.name = 'TokenError';
  67. this.provider = provider;
  68. if (Error.captureStackTrace) {
  69. Error.captureStackTrace(this, TokenError);
  70. }
  71. }
  72. }
  73. return {
  74. buildKeys(name, hooks) {
  75. const prefix = `secure/token/${hooks ? hooks.keyName(name) : name}/`;
  76. const k = {
  77. TOKEN: `${prefix}token`,
  78. EXPIRE: `${prefix}expire`,
  79. REFRESH: `${prefix}refresh`,
  80. };
  81. k.LIST = Object.values(k);
  82. return k;
  83. },
  84. getClientId(name) {
  85. return AUTH[name].clientId;
  86. },
  87. async getToken(name, interactive, hooks) {
  88. const k = tokenMan.buildKeys(name, hooks);
  89. const obj = await chromeLocal.get(k.LIST);
  90. if (obj[k.TOKEN]) {
  91. if (!obj[k.EXPIRE] || Date.now() < obj[k.EXPIRE]) {
  92. return obj[k.TOKEN];
  93. }
  94. if (obj[k.REFRESH]) {
  95. return refreshToken(name, k, obj);
  96. }
  97. }
  98. if (!interactive) {
  99. throw new TokenError(name, 'Token is missing');
  100. }
  101. return authUser(k, name, interactive, hooks);
  102. },
  103. async revokeToken(name, hooks) {
  104. const provider = AUTH[name];
  105. const k = tokenMan.buildKeys(name, hooks);
  106. if (provider.revoke) {
  107. try {
  108. const token = await chromeLocal.getValue(k.TOKEN);
  109. if (token) await provider.revoke(token);
  110. } catch (e) {
  111. console.error(e);
  112. }
  113. }
  114. await chromeLocal.remove(k.LIST);
  115. },
  116. };
  117. async function refreshToken(name, k, obj) {
  118. if (!obj[k.REFRESH]) {
  119. throw new TokenError(name, 'No refresh token');
  120. }
  121. const provider = AUTH[name];
  122. const body = {
  123. client_id: provider.clientId,
  124. refresh_token: obj[k.REFRESH],
  125. grant_type: 'refresh_token',
  126. scope: provider.scopes.join(' '),
  127. };
  128. if (provider.clientSecret) {
  129. body.client_secret = provider.clientSecret;
  130. }
  131. const result = await postQuery(provider.tokenURL, body);
  132. if (!result.refresh_token) {
  133. // reuse old refresh token
  134. result.refresh_token = obj[k.REFRESH];
  135. }
  136. return handleTokenResult(result, k);
  137. }
  138. async function authUser(keys, name, interactive = false, hooks = null) {
  139. await require(['/vendor/webext-launch-web-auth-flow/webext-launch-web-auth-flow.min']);
  140. /* global webextLaunchWebAuthFlow */
  141. const provider = AUTH[name];
  142. const state = Math.random().toFixed(8).slice(2);
  143. const query = {
  144. response_type: provider.flow,
  145. client_id: provider.clientId,
  146. redirect_uri: provider.redirect_uri || chrome.identity.getRedirectURL(),
  147. state,
  148. };
  149. if (provider.scopes) {
  150. query.scope = provider.scopes.join(' ');
  151. }
  152. if (provider.authQuery) {
  153. Object.assign(query, provider.authQuery);
  154. }
  155. if (alwaysUseTab == null) {
  156. alwaysUseTab = await detectVivaldiWebRequestBug();
  157. }
  158. if (hooks) hooks.query(query);
  159. const url = `${provider.authURL}?${new URLSearchParams(query)}`;
  160. const width = Math.min(screen.availWidth - 100, 800);
  161. const height = Math.min(screen.availHeight - 100, 800);
  162. const wnd = await browser.windows.getLastFocused();
  163. const finalUrl = await webextLaunchWebAuthFlow({
  164. url,
  165. alwaysUseTab,
  166. interactive,
  167. redirect_uri: query.redirect_uri,
  168. windowOptions: Object.assign({
  169. state: 'normal',
  170. width,
  171. height,
  172. }, wnd.state !== 'minimized' && {
  173. // Center the popup to the current window
  174. top: Math.ceil(wnd.top + (wnd.height - width) / 2),
  175. left: Math.ceil(wnd.left + (wnd.width - width) / 2),
  176. }),
  177. });
  178. const params = new URLSearchParams(
  179. provider.flow === 'token' ?
  180. new URL(finalUrl).hash.slice(1) :
  181. new URL(finalUrl).search.slice(1)
  182. );
  183. if (params.get('state') !== state) {
  184. throw new TokenError(name, `Unexpected state: ${params.get('state')}, expected: ${state}`);
  185. }
  186. let result;
  187. if (provider.flow === 'token') {
  188. const obj = {};
  189. for (const [key, value] of params) {
  190. obj[key] = value;
  191. }
  192. result = obj;
  193. } else {
  194. const code = params.get('code');
  195. const body = {
  196. code,
  197. grant_type: 'authorization_code',
  198. client_id: provider.clientId,
  199. redirect_uri: query.redirect_uri,
  200. state,
  201. };
  202. if (provider.clientSecret) {
  203. body.client_secret = provider.clientSecret;
  204. }
  205. result = await postQuery(provider.tokenURL, body);
  206. }
  207. return handleTokenResult(result, keys);
  208. }
  209. async function handleTokenResult(result, k) {
  210. await chromeLocal.set({
  211. [k.TOKEN]: result.access_token,
  212. [k.EXPIRE]: result.expires_in
  213. ? Date.now() + (result.expires_in - NETWORK_LATENCY) * 1000
  214. : undefined,
  215. [k.REFRESH]: result.refresh_token,
  216. });
  217. return result.access_token;
  218. }
  219. async function postQuery(url, body) {
  220. const options = {
  221. method: 'POST',
  222. headers: {
  223. 'Content-Type': 'application/x-www-form-urlencoded',
  224. },
  225. body: body ? new URLSearchParams(body) : null,
  226. };
  227. const r = await fetch(url, options);
  228. if (r.ok) {
  229. return r.json();
  230. }
  231. const text = await r.text();
  232. const err = new Error(`Failed to fetch (${r.status}): ${text}`);
  233. err.code = r.status;
  234. throw err;
  235. }
  236. async function detectVivaldiWebRequestBug() {
  237. // Workaround for https://github.com/openstyles/stylus/issues/1182
  238. // Note that modern Vivaldi isn't exposed in `navigator.userAgent` but it adds `extData` to tabs
  239. const anyTab = await getActiveTab() || (await browser.tabs.query({}))[0];
  240. if (anyTab && !anyTab.extData) {
  241. return false;
  242. }
  243. let bugged = true;
  244. const TEST_URL = chrome.runtime.getURL('manifest.json');
  245. const check = ({url}) => {
  246. bugged = url !== TEST_URL;
  247. };
  248. chrome.webRequest.onBeforeRequest.addListener(check, {urls: [TEST_URL], types: ['main_frame']});
  249. const {tabs: [tab]} = await browser.windows.create({
  250. type: 'popup',
  251. state: 'minimized',
  252. url: TEST_URL,
  253. });
  254. await waitForTabUrl(tab);
  255. chrome.windows.remove(tab.windowId);
  256. chrome.webRequest.onBeforeRequest.removeListener(check);
  257. return bugged;
  258. }
  259. })();