forked from trezor/trezor-suite
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathgoogle.ts
449 lines (399 loc) · 15.4 KB
/
google.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
/* eslint camelcase: 0 */
/**
* This is a custom implementation of Google OAuth authorization code flow with loopback IP address and implicit flow.
* The authorization code flow is default for desktop, while the implicit flow is used on web and as a fallback for desktop
* in case our authorization server (which holds a client secret necessary for the authorization code flow) is not available.
*/
import { METADATA } from '@suite-actions/constants';
import { isDesktop } from '@suite-utils/env';
import { OAuthServerEnvironment, Tokens } from '@suite-types/metadata';
import { extractCredentialsFromAuthorizationFlow, getOauthReceiverUrl } from '@suite-utils/oauth';
import { getCodeChallenge } from '@suite-utils/random';
const SCOPES = 'https://www.googleapis.com/auth/drive.appdata';
const BOUNDARY = '-------314159265358979323846';
type QueryParams = {
q?: string;
alt?: string;
spaces?: string;
fields?: string;
pageSize?: number;
};
type BodyParams =
| {
mimeType?: string;
parents?: [string];
name?: string;
}
| string;
interface ListParams {
query?: QueryParams;
body?: never;
}
type GetParams = {
query?: QueryParams;
body?: never;
};
type UpdateParams = {
query?: never;
body: BodyParams;
};
type CreateParams = {
query?: never;
body: BodyParams;
};
type ApiParams = ListParams | GetParams | UpdateParams | CreateParams;
type ListResponse = {
files: [
{
kind: string;
id: string;
name: string;
mimeType: string;
},
];
};
type CreateResponse = {
kind: string;
id: string;
name: string;
mimeType: string;
};
type GetTokenInfoResponse = {
token: string;
type: 'google';
user: {
displayName: string;
};
};
type Flow = 'implicit' | 'code';
/**
* This class provides communication interface with selected google rest APIs:
* - oauth v2
* - drive v3
*/
class Client {
static nameIdMap: Record<string, string>;
static listPromise?: Promise<ListResponse>;
static flow: Flow;
static clientId = '';
static authServerAvailable = false;
static initPromise: Promise<Client> | undefined;
static accessToken: string;
static refreshToken: string;
static authServerUrl: string;
static servers = {
production: 'https://suite-auth.trezor.io',
staging: 'https://staging-suite-auth.trezor.io',
localhost: 'http://localhost:3005',
};
public static setEnvironment(environment: OAuthServerEnvironment) {
Client.authServerUrl = Client.servers[environment];
}
static init({ accessToken, refreshToken }: Tokens, environment: OAuthServerEnvironment) {
Client.initPromise = new Promise(resolve => {
Client.nameIdMap = {};
Client.setEnvironment(environment);
if (refreshToken) {
Client.refreshToken = refreshToken;
}
if (accessToken) {
Client.accessToken = accessToken;
}
if (isDesktop()) {
Client.isAuthServerAvailable().then(result => {
// if our server providing the refresh token is not available, fallback to a flow with access tokens only (authorization for a limited time)
Client.flow = result ? 'code' : 'implicit';
// the app has two sets of credentials to enable both OAuth flows
Client.clientId =
Client.flow === 'code'
? METADATA.GOOGLE_CODE_FLOW_CLIENT_ID
: METADATA.GOOGLE_IMPLICIT_FLOW_CLIENT_ID;
resolve(Client);
});
} else {
// code flow with loopback IP address does not work unless redirect_uri is localhost (Google returns redirect_uri_mismatch)
Client.flow = 'implicit';
Client.clientId = METADATA.GOOGLE_IMPLICIT_FLOW_CLIENT_ID;
resolve(Client);
}
});
}
static async getAccessToken() {
await Client.initPromise;
if (!Client.accessToken && Client.refreshToken && Client.flow === 'code') {
try {
const res = await fetch(`${Client.authServerUrl}/google-oauth-refresh`, {
method: 'POST',
body: JSON.stringify({
clientId: Client.clientId,
refreshToken: Client.refreshToken,
}),
headers: {
'Content-Type': 'application/json',
},
});
const json = await res.json();
if (!json?.access_token) {
throw new Error('Could not refresh access token.');
}
Client.accessToken = json.access_token;
} catch {
await Client.forceImplicitFlow();
}
}
return Client.accessToken;
}
static async isAuthServerAvailable() {
try {
Client.authServerAvailable = (await fetch(`${Client.authServerUrl}/status`)).ok;
} catch (err) {
Client.authServerAvailable = false;
}
return Client.authServerAvailable;
}
static async authorize() {
await Client.initPromise;
const redirectUri = await getOauthReceiverUrl();
if (!redirectUri) return;
const random = getCodeChallenge();
const options = {
client_id: Client.clientId,
redirect_uri: redirectUri,
scope: SCOPES,
};
if (Client.flow === 'code') {
// authorization code flow with PKCE
Object.assign(options, {
code_challenge: random,
code_challenge_method: 'plain',
response_type: 'code',
});
} else {
// implicit flow
Object.assign(options, {
response_type: 'token',
});
}
const url = `https://accounts.google.com/o/oauth2/v2/auth?${new URLSearchParams(
options,
).toString()}`;
const response = await extractCredentialsFromAuthorizationFlow(url);
const { access_token, code } = response;
if (access_token) {
// implicit flow returns short lived access_token directly
Client.accessToken = access_token;
} else {
// authorization code flow retrieves code, then refresh_token, which can generate access_token on demand
try {
const res = await fetch(`${Client.authServerUrl}/google-oauth-init`, {
method: 'POST',
body: JSON.stringify({
clientId: Client.clientId,
code,
codeVerifier: random,
redirectUri,
}),
headers: {
'Content-Type': 'application/json',
},
});
const json = await res.json();
if (!json?.access_token || !json?.refresh_token) {
throw new Error('Could not retrieve the tokens.');
}
Client.accessToken = json.access_token;
Client.refreshToken = json.refresh_token;
} catch {
await Client.forceImplicitFlow();
}
}
}
// when auth server is running, but returns an unexpected response, fall back to implicit flow
// TODO: this does not work if browser blocks pop-up windows and it opens two tabs/windows, there could be a better solution
static async forceImplicitFlow() {
if (Client.flow === 'code') {
Client.flow = 'implicit';
Client.clientId = METADATA.GOOGLE_IMPLICIT_FLOW_CLIENT_ID;
await Client.authorize();
}
}
/**
* implementation of https://developers.google.com/identity/protocols/oauth2/javascript-implicit-flow#tokenrevoke
*/
static revoke() {
// revoking an access token also invalidates any corresponding refresh token
const promise = Client.call(
`https://oauth2.googleapis.com/revoke?token=${Client.accessToken}`,
{
method: 'POST',
},
);
Client.accessToken = '';
Client.refreshToken = '';
return promise;
}
static async getTokenInfo(): Promise<GetTokenInfoResponse> {
const response = await Client.call(
`https://www.googleapis.com/drive/v3/about?fields=user`,
{ method: 'GET' },
{},
);
return response.json();
}
/**
* implementation of https://developers.google.com/drive/api/v3/reference/files/list
*/
static async list(params: ListParams): Promise<ListResponse> {
const response = await Client.call(
'https://www.googleapis.com/drive/v3/files',
{
method: 'GET',
},
params,
);
const json: ListResponse = await response.json();
Client.nameIdMap = {};
json.files.forEach(file => {
Client.nameIdMap[file.name] = file.id;
});
// hmm this is a rare case that probably can't be solved in elegant way. User may somehow (manually, or some race condition)
// create multiple files with same name (file.mtdt, file.mtdt). They can both exist in Google Drive simultaneously and
// drive has no problem with it as they have different id. What makes this case even more confusing is that list requests
// returns array of files in randomized order. So user is seeing and is saving his data in one session to file.mtdt(id: A) but
// then to file.mtdt(id: B) in another session. So this warn should help as debug if this mysterious bug appears some day...
if (Object.keys(Client.nameIdMap).length < json.files.length) {
console.warn(
'There are multiple files with the same name in Google Drive. This may happen as a result of race condition bug in application.',
);
}
return json;
}
/**
* implementation of https://developers.google.com/drive/api/v3/reference/files/get
*/
static async get(params: GetParams, id: string) {
const response = await Client.call(
`https://www.googleapis.com/drive/v3/files/${id}`,
{
method: 'GET',
},
params,
);
return response.text();
}
/**
* implementation of https://developers.google.com/drive/api/v3/reference/files/create
*/
static async create(params: CreateParams, payload: string): Promise<CreateResponse> {
params.body = Client.getWriteBody(params.body, payload);
const response = await Client.call(
'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart',
{
method: 'POST',
headers: {
'Content-Type': `multipart/related; boundary="${BOUNDARY}"`,
},
},
params,
);
return response.json();
}
/**
* implementation of https://developers.google.com/drive/api/v3/reference/files/update
*/
static async update(params: UpdateParams, payload: string, id: string) {
params.body = Client.getWriteBody(params.body, payload);
const response = await Client.call(
`https://www.googleapis.com/upload/drive/v3/files/${id}?uploadType=multipart`,
{
method: 'PATCH',
headers: {
'Content-Type': `multipart/related; boundary="${BOUNDARY}"`,
},
},
params,
);
return response.json;
}
/**
* tldr: utility function that performs file search by its name and returns file name if file was found, otherwise returns undefined
*
* full story:
* Google Drive does not support "get file by path or name concept", you can get file only by id. To get id you must use list api call.
* so in theory, you would need 2 calls to get single file: first get list of files from which you would filter file id and then get call.
* to avoid this, google class holds map of name-ids and performs list request only if it could not find required name in the map.
*/
static async getIdByName(name: string, forceReload = false) {
if (!forceReload && Client.nameIdMap[name]) {
return Client.nameIdMap[name];
}
try {
// request to list files might have already been dispatched and exist as unresolved promise, so wait for it here in that case
if (Client.listPromise) {
await Client.listPromise;
Client.listPromise = undefined; // unset
return Client.nameIdMap[name];
}
// refresh nameIdMap
Client.listPromise = Client.list({
query: { spaces: 'appDataFolder' },
});
await Client.listPromise;
} finally {
Client.listPromise = undefined; // unset
}
// request to list files might have already been dispatched and exist as unresolved promise, so wait for it here in that case
return Client.nameIdMap[name];
}
private static getWriteBody(
body: CreateParams['body'] | UpdateParams['body'],
payload: string,
) {
const delimiter = `\r\n--${BOUNDARY}\r\n`;
const closeDelimiter = `\r\n--${BOUNDARY}--`;
const contentType = 'text/plain;charset=UTF-8';
const multipartRequestBody = `${delimiter}Content-Type: application/json\r\n\r\n${JSON.stringify(
body,
)}${delimiter}Content-Type: ${contentType}\r\n\r\n${payload}${closeDelimiter}`;
return multipartRequestBody;
}
private static async call(url: string, fetchParams: RequestInit, apiParams?: ApiParams) {
if (apiParams?.query) {
const query = new URLSearchParams(apiParams.query as Record<string, string>).toString();
url += `?${query}`;
}
const fetchOptions = {
...fetchParams,
headers: {
'Content-Type': 'application/json',
...fetchParams.headers,
},
};
if (apiParams?.body) {
const body =
typeof apiParams.body === 'string'
? apiParams.body
: JSON.stringify(apiParams.body);
Object.assign(fetchOptions, { body });
}
const getTokenAndFetch = async (isRetry?: boolean) => {
await Client.getAccessToken();
Object.assign(fetchOptions.headers, {
Authorization: `Bearer ${Client.accessToken}`,
});
let response = await fetch(url, fetchOptions);
if (!isRetry && response.status === 401 && Client.refreshToken) {
// refresh access token if expired and attempt the request again
Client.accessToken = '';
response = await getTokenAndFetch(true);
} else if (response.status !== 200) {
const error = await response.json();
throw error;
}
return response;
};
const response = await getTokenAndFetch();
return response;
}
}
export default Client;