123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293 |
- import assets from '../common/assets';
- import { getFileList, setFileList } from './api';
- import { encryptStream, decryptStream } from './ece';
- import { arrayToB64, b64ToArray, streamToArrayBuffer } from './utils';
- import { blobStream } from './streams';
- import { getFileListKey, prepareScopedBundleKey, preparePkce } from './fxa';
- import storage from './storage';
- const textEncoder = new TextEncoder();
- const textDecoder = new TextDecoder();
- const anonId = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
- async function hashId(id) {
- const d = new Date();
- const month = d.getUTCMonth();
- const year = d.getUTCFullYear();
- const encoded = textEncoder.encode(`${id}:${year}:${month}`);
- const hash = await crypto.subtle.digest('SHA-256', encoded);
- return arrayToB64(new Uint8Array(hash.slice(16)));
- }
- export default class User {
- constructor(storage, limits, authConfig) {
- this.authConfig = authConfig;
- this.limits = limits;
- this.storage = storage;
- this.data = storage.user || {};
- }
- get info() {
- return this.data || this.storage.user || {};
- }
- set info(data) {
- this.data = data;
- this.storage.user = data;
- }
- get firstAction() {
- return this.storage.get('firstAction');
- }
- set firstAction(action) {
- this.storage.set('firstAction', action);
- }
- get surveyed() {
- return this.storage.get('surveyed');
- }
- set surveyed(yes) {
- this.storage.set('surveyed', yes);
- }
- get avatar() {
- const defaultAvatar = assets.get('user.svg');
- if (this.info.avatarDefault) {
- return defaultAvatar;
- }
- return this.info.avatar || defaultAvatar;
- }
- get name() {
- return this.info.displayName;
- }
- get email() {
- return this.info.email;
- }
- get loggedIn() {
- return !!this.info.access_token;
- }
- get bearerToken() {
- return this.info.access_token;
- }
- get refreshToken() {
- return this.info.refresh_token;
- }
- get maxSize() {
- return this.limits.MAX_FILE_SIZE;
- }
- get maxExpireSeconds() {
- return this.limits.MAX_EXPIRE_SECONDS;
- }
- get maxDownloads() {
- return this.limits.MAX_DOWNLOADS;
- }
- async metricId() {
- return this.loggedIn ? hashId(this.info.uid) : undefined;
- }
- async deviceId() {
- return this.loggedIn ? hashId(this.storage.id) : hashId(anonId);
- }
- async startAuthFlow(trigger, utms = {}) {
- this.utms = utms;
- this.trigger = trigger;
- this.flowId = null;
- this.flowBeginTime = null;
- }
- async login(email) {
- const state = arrayToB64(crypto.getRandomValues(new Uint8Array(16)));
- storage.set('oauthState', state);
- const keys_jwk = await prepareScopedBundleKey(this.storage);
- const code_challenge = await preparePkce(this.storage);
- const options = {
- action: 'email',
- access_type: 'offline',
- client_id: this.authConfig.client_id,
- code_challenge,
- code_challenge_method: 'S256',
- response_type: 'code',
- scope: `profile ${this.authConfig.key_scope}`,
- state,
- keys_jwk
- };
- if (email) {
- options.email = email;
- }
- if (this.flowId && this.flowBeginTime) {
- options.flow_id = this.flowId;
- options.flow_begin_time = this.flowBeginTime;
- }
- if (this.trigger) {
- options.entrypoint = `send-${this.trigger}`;
- }
- if (this.utms) {
- options.utm_campaign = this.utms.campaign || 'none';
- options.utm_content = this.utms.content || 'none';
- options.utm_medium = this.utms.medium || 'none';
- options.utm_source = this.utms.source || 'send';
- options.utm_term = this.utms.term || 'none';
- }
- const params = new URLSearchParams(options);
- location.assign(
- `${this.authConfig.authorization_endpoint}?${params.toString()}`
- );
- }
- async finishLogin(code, state) {
- const localState = storage.get('oauthState');
- storage.remove('oauthState');
- if (state !== localState) {
- throw new Error('state mismatch');
- }
- const tokenResponse = await fetch(this.authConfig.token_endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- code,
- client_id: this.authConfig.client_id,
- code_verifier: this.storage.get('pkceVerifier')
- })
- });
- const auth = await tokenResponse.json();
- const infoResponse = await fetch(this.authConfig.userinfo_endpoint, {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${auth.access_token}`
- }
- });
- const userInfo = await infoResponse.json();
- userInfo.access_token = auth.access_token;
- userInfo.refresh_token = auth.refresh_token;
- userInfo.fileListKey = await getFileListKey(this.storage, auth.keys_jwe);
- this.info = userInfo;
- this.storage.remove('pkceVerifier');
- }
- async refresh() {
- if (!this.refreshToken) {
- return false;
- }
- try {
- const tokenResponse = await fetch(this.authConfig.token_endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- client_id: this.authConfig.client_id,
- grant_type: 'refresh_token',
- refresh_token: this.refreshToken
- })
- });
- if (tokenResponse.ok) {
- const auth = await tokenResponse.json();
- const info = { ...this.info, access_token: auth.access_token };
- this.info = info;
- return true;
- }
- } catch (e) {
- console.error(e);
- }
- await this.logout();
- return false;
- }
- async logout() {
- try {
- if (this.refreshToken) {
- await fetch(this.authConfig.revocation_endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- refresh_token: this.refreshToken
- })
- });
- }
- if (this.bearerToken) {
- await fetch(this.authConfig.revocation_endpoint, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({
- token: this.bearerToken
- })
- });
- }
- } catch (e) {
- console.error(e);
- // oh well, we tried
- }
- this.storage.clearLocalFiles();
- this.info = {};
- }
- async syncFileList() {
- let changes = { incoming: false, outgoing: false, downloadCount: false };
- if (!this.loggedIn) {
- return this.storage.merge();
- }
- let list = [];
- const key = b64ToArray(this.info.fileListKey);
- const sha = await crypto.subtle.digest('SHA-256', key);
- const kid = arrayToB64(new Uint8Array(sha)).substring(0, 16);
- const retry = async () => {
- const refreshed = await this.refresh();
- if (refreshed) {
- return await this.syncFileList();
- } else {
- return { incoming: true };
- }
- };
- try {
- const encrypted = await getFileList(this.bearerToken, kid);
- const decrypted = await streamToArrayBuffer(
- decryptStream(blobStream(encrypted), key)
- );
- list = JSON.parse(textDecoder.decode(decrypted));
- } catch (e) {
- if (e.message === '401') {
- return retry(e);
- }
- }
- changes = await this.storage.merge(list);
- if (!changes.outgoing) {
- return changes;
- }
- try {
- const blob = new Blob([
- textEncoder.encode(JSON.stringify(this.storage.files))
- ]);
- const encrypted = await streamToArrayBuffer(
- encryptStream(blobStream(blob), key)
- );
- await setFileList(this.bearerToken, kid, encrypted);
- } catch (e) {
- if (e.message === '401') {
- return retry(e);
- }
- }
- return changes;
- }
- toJSON() {
- return this.info;
- }
- }
|