user.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. import assets from '../common/assets';
  2. import { getFileList, setFileList } from './api';
  3. import { encryptStream, decryptStream } from './ece';
  4. import { arrayToB64, b64ToArray, streamToArrayBuffer } from './utils';
  5. import { blobStream } from './streams';
  6. import { getFileListKey, prepareScopedBundleKey, preparePkce } from './fxa';
  7. import storage from './storage';
  8. const textEncoder = new TextEncoder();
  9. const textDecoder = new TextDecoder();
  10. const anonId = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
  11. async function hashId(id) {
  12. const d = new Date();
  13. const month = d.getUTCMonth();
  14. const year = d.getUTCFullYear();
  15. const encoded = textEncoder.encode(`${id}:${year}:${month}`);
  16. const hash = await crypto.subtle.digest('SHA-256', encoded);
  17. return arrayToB64(new Uint8Array(hash.slice(16)));
  18. }
  19. export default class User {
  20. constructor(storage, limits, authConfig) {
  21. this.authConfig = authConfig;
  22. this.limits = limits;
  23. this.storage = storage;
  24. this.data = storage.user || {};
  25. }
  26. get info() {
  27. return this.data || this.storage.user || {};
  28. }
  29. set info(data) {
  30. this.data = data;
  31. this.storage.user = data;
  32. }
  33. get firstAction() {
  34. return this.storage.get('firstAction');
  35. }
  36. set firstAction(action) {
  37. this.storage.set('firstAction', action);
  38. }
  39. get surveyed() {
  40. return this.storage.get('surveyed');
  41. }
  42. set surveyed(yes) {
  43. this.storage.set('surveyed', yes);
  44. }
  45. get avatar() {
  46. const defaultAvatar = assets.get('user.svg');
  47. if (this.info.avatarDefault) {
  48. return defaultAvatar;
  49. }
  50. return this.info.avatar || defaultAvatar;
  51. }
  52. get name() {
  53. return this.info.displayName;
  54. }
  55. get email() {
  56. return this.info.email;
  57. }
  58. get loggedIn() {
  59. return !!this.info.access_token;
  60. }
  61. get bearerToken() {
  62. return this.info.access_token;
  63. }
  64. get refreshToken() {
  65. return this.info.refresh_token;
  66. }
  67. get maxSize() {
  68. return this.loggedIn
  69. ? this.limits.MAX_FILE_SIZE
  70. : this.limits.ANON.MAX_FILE_SIZE;
  71. }
  72. get maxExpireSeconds() {
  73. return this.loggedIn
  74. ? this.limits.MAX_EXPIRE_SECONDS
  75. : this.limits.ANON.MAX_EXPIRE_SECONDS;
  76. }
  77. get maxDownloads() {
  78. return this.loggedIn
  79. ? this.limits.MAX_DOWNLOADS
  80. : this.limits.ANON.MAX_DOWNLOADS;
  81. }
  82. get loginRequired() {
  83. return this.authConfig && this.authConfig.fxa_required;
  84. }
  85. async metricId() {
  86. return this.loggedIn ? hashId(this.info.uid) : undefined;
  87. }
  88. async deviceId() {
  89. return this.loggedIn ? hashId(this.storage.id) : hashId(anonId);
  90. }
  91. async startAuthFlow(trigger, utms = {}) {
  92. this.utms = utms;
  93. this.trigger = trigger;
  94. try {
  95. const params = new URLSearchParams({
  96. entrypoint: `send-${trigger}`,
  97. form_type: 'email',
  98. utm_source: utms.source || 'send',
  99. utm_campaign: utms.campaign || 'none'
  100. });
  101. const res = await fetch(
  102. `${this.authConfig.issuer}/metrics-flow?${params.toString()}`,
  103. {
  104. mode: 'cors'
  105. }
  106. );
  107. const { flowId, flowBeginTime } = await res.json();
  108. this.flowId = flowId;
  109. this.flowBeginTime = flowBeginTime;
  110. } catch (e) {
  111. console.error(e);
  112. this.flowId = null;
  113. this.flowBeginTime = null;
  114. }
  115. }
  116. async login(email) {
  117. const state = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
  118. storage.set('oauthState', state);
  119. const keys_jwk = await prepareScopedBundleKey(this.storage);
  120. const code_challenge = await preparePkce(this.storage);
  121. const options = {
  122. action: 'email',
  123. access_type: 'offline',
  124. client_id: this.authConfig.client_id,
  125. code_challenge,
  126. code_challenge_method: 'S256',
  127. response_type: 'code',
  128. scope: `profile ${this.authConfig.key_scope}`,
  129. state,
  130. keys_jwk
  131. };
  132. if (email) {
  133. options.email = email;
  134. }
  135. if (this.flowId && this.flowBeginTime) {
  136. options.flow_id = this.flowId;
  137. options.flow_begin_time = this.flowBeginTime;
  138. }
  139. if (this.trigger) {
  140. options.entrypoint = `send-${this.trigger}`;
  141. }
  142. if (this.utms) {
  143. options.utm_campaign = this.utms.campaign || 'none';
  144. options.utm_content = this.utms.content || 'none';
  145. options.utm_medium = this.utms.medium || 'none';
  146. options.utm_source = this.utms.source || 'send';
  147. options.utm_term = this.utms.term || 'none';
  148. }
  149. const params = new URLSearchParams(options);
  150. location.assign(
  151. `${this.authConfig.authorization_endpoint}?${params.toString()}`
  152. );
  153. }
  154. async finishLogin(code, state) {
  155. const localState = storage.get('oauthState');
  156. storage.remove('oauthState');
  157. if (state !== localState) {
  158. throw new Error('state mismatch');
  159. }
  160. const tokenResponse = await fetch(this.authConfig.token_endpoint, {
  161. method: 'POST',
  162. headers: {
  163. 'Content-Type': 'application/json'
  164. },
  165. body: JSON.stringify({
  166. code,
  167. client_id: this.authConfig.client_id,
  168. code_verifier: this.storage.get('pkceVerifier')
  169. })
  170. });
  171. const auth = await tokenResponse.json();
  172. const infoResponse = await fetch(this.authConfig.userinfo_endpoint, {
  173. method: 'GET',
  174. headers: {
  175. Authorization: `Bearer ${auth.access_token}`
  176. }
  177. });
  178. const userInfo = await infoResponse.json();
  179. userInfo.access_token = auth.access_token;
  180. userInfo.refresh_token = auth.refresh_token;
  181. userInfo.fileListKey = await getFileListKey(this.storage, auth.keys_jwe);
  182. this.info = userInfo;
  183. this.storage.remove('pkceVerifier');
  184. }
  185. async refresh() {
  186. if (!this.refreshToken) {
  187. return false;
  188. }
  189. try {
  190. const tokenResponse = await fetch(this.authConfig.token_endpoint, {
  191. method: 'POST',
  192. headers: {
  193. 'Content-Type': 'application/json'
  194. },
  195. body: JSON.stringify({
  196. client_id: this.authConfig.client_id,
  197. grant_type: 'refresh_token',
  198. refresh_token: this.refreshToken
  199. })
  200. });
  201. if (tokenResponse.ok) {
  202. const auth = await tokenResponse.json();
  203. const info = { ...this.info, access_token: auth.access_token };
  204. this.info = info;
  205. return true;
  206. }
  207. } catch (e) {
  208. console.error(e);
  209. }
  210. await this.logout();
  211. return false;
  212. }
  213. async logout() {
  214. try {
  215. if (this.refreshToken) {
  216. await fetch(this.authConfig.revocation_endpoint, {
  217. method: 'POST',
  218. headers: {
  219. 'Content-Type': 'application/json'
  220. },
  221. body: JSON.stringify({
  222. refresh_token: this.refreshToken
  223. })
  224. });
  225. }
  226. if (this.bearerToken) {
  227. await fetch(this.authConfig.revocation_endpoint, {
  228. method: 'POST',
  229. headers: {
  230. 'Content-Type': 'application/json'
  231. },
  232. body: JSON.stringify({
  233. token: this.bearerToken
  234. })
  235. });
  236. }
  237. } catch (e) {
  238. console.error(e);
  239. // oh well, we tried
  240. }
  241. this.storage.clearLocalFiles();
  242. this.info = {};
  243. }
  244. async syncFileList() {
  245. let changes = { incoming: false, outgoing: false, downloadCount: false };
  246. if (!this.loggedIn) {
  247. return this.storage.merge();
  248. }
  249. let list = [];
  250. const key = b64ToArray(this.info.fileListKey);
  251. const sha = await crypto.subtle.digest('SHA-256', key);
  252. const kid = arrayToB64(new Uint8Array(sha)).substring(0, 16);
  253. const retry = async () => {
  254. const refreshed = await this.refresh();
  255. if (refreshed) {
  256. return await this.syncFileList();
  257. } else {
  258. return { incoming: true };
  259. }
  260. };
  261. try {
  262. const encrypted = await getFileList(this.bearerToken, kid);
  263. const decrypted = await streamToArrayBuffer(
  264. decryptStream(blobStream(encrypted), key)
  265. );
  266. list = JSON.parse(textDecoder.decode(decrypted));
  267. } catch (e) {
  268. if (e.message === '401') {
  269. return retry(e);
  270. }
  271. }
  272. changes = await this.storage.merge(list);
  273. if (!changes.outgoing) {
  274. return changes;
  275. }
  276. try {
  277. const blob = new Blob([
  278. textEncoder.encode(JSON.stringify(this.storage.files))
  279. ]);
  280. const encrypted = await streamToArrayBuffer(
  281. encryptStream(blobStream(blob), key)
  282. );
  283. await setFileList(this.bearerToken, kid, encrypted);
  284. } catch (e) {
  285. if (e.message === '401') {
  286. return retry(e);
  287. }
  288. }
  289. return changes;
  290. }
  291. toJSON() {
  292. return this.info;
  293. }
  294. }