-
Notifications
You must be signed in to change notification settings - Fork 586
/
Copy pathApp.ts
407 lines (376 loc) · 13.7 KB
/
App.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
////////////////////////////////////////////////////////////////////////////
//
// Copyright 2020 Realm Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
////////////////////////////////////////////////////////////////////////////
import { fetch } from "@realm/fetch";
import { ObjectId } from "bson";
import { User, UserState } from "./User";
import { Credentials, ProviderType } from "./Credentials";
import { EmailPasswordAuth } from "./auth-providers";
import { Storage } from "./storage";
import { AppStorage } from "./AppStorage";
import { getEnvironment } from "./environment";
import { AuthResponse, Authenticator } from "./Authenticator";
import { FetchFunction, Fetcher, UserContext } from "./Fetcher";
import routes from "./routes";
import { DeviceInformation, DEVICE_ID_STORAGE_KEY } from "./DeviceInformation";
type SimpleObject = Record<string, unknown>;
/**
* Default base url to prefix all requests if no baseUrl is specified in the configuration.
*/
export const DEFAULT_BASE_URL = "https://services.cloud.mongodb.com";
/**
* Configuration to pass as an argument when constructing an app.
*/
export interface AppConfiguration extends Realm.AppConfiguration {
/**
* Transport to use when fetching resources.
*/
fetch?: FetchFunction;
/**
* Used when persisting app state, such as tokens of authenticated users.
*/
storage?: Storage;
/**
* Skips requesting a location URL via the baseUrl and use the `baseUrl` as the url prefixed for any requests initiated by this app.
* This can useful when connecting to a server through a reverse proxy, to avoid the location request to make the client "break out" and start requesting another server.
*/
skipLocationRequest?: boolean;
}
/**
* Atlas App Services Application
*/
export class App<
FunctionsFactoryType = Realm.DefaultFunctionsFactory & Realm.BaseFunctionsFactory,
CustomDataType = SimpleObject,
> implements Realm.App<FunctionsFactoryType, CustomDataType>
{
/**
* A map of app instances returned from calling getApp.
*/
private static appCache: { [id: string]: App } = {};
/**
* Get or create a singleton Realm App from an id.
* Calling this function multiple times with the same id will return the same instance.
* @param id The Realm App id visible from the Atlas App Services UI or a configuration.
* @returns The Realm App instance.
*/
static getApp(id: string): App {
if (id in App.appCache) {
return App.appCache[id];
} else {
const instance = new App(id);
App.appCache[id] = instance;
return instance;
}
}
/** @inheritdoc */
public readonly id: string;
/**
* Instances of this class can be passed to the `app.logIn` method to authenticate an end-user.
*/
public static readonly Credentials = Credentials;
/**
* An object which can be used to fetch responses from the server.
*/
public readonly fetcher: Fetcher;
/** @inheritdoc */
public readonly emailPasswordAuth: EmailPasswordAuth;
/**
* Storage available for the app.
*/
public readonly storage: AppStorage;
/**
* Internal authenticator used to complete authentication requests.
*/
public readonly authenticator: Authenticator;
/**
* An array of active and logged-out users.
* Elements in the beginning of the array is considered more recent than the later elements.
*/
private users: User<FunctionsFactoryType, CustomDataType>[] = [];
/**
* The base URL of the app.
*/
private readonly baseUrl: string;
/**
* Local app configuration.
* Useful to determine what name and version an authenticated user is running.
*/
private readonly localApp: Realm.LocalAppConfiguration | undefined;
/**
* A promise resolving to the App's location url.
*/
private _locationUrl: Promise<string> | null = null;
/**
* Construct a Realm App, either from the Realm App id visible from the Atlas App Services UI or a configuration.
* @param idOrConfiguration The Realm App id or a configuration to use for this app.
*/
constructor(idOrConfiguration: string | AppConfiguration) {
// If the argument is a string, convert it to a simple configuration object.
const configuration = typeof idOrConfiguration === "string" ? { id: idOrConfiguration } : idOrConfiguration;
// Initialize properties from the configuration
if (typeof configuration === "object" && typeof configuration.id === "string") {
this.id = configuration.id;
} else {
throw new Error("Missing an Atlas App Services app-id");
}
this.baseUrl = configuration.baseUrl || DEFAULT_BASE_URL;
if (configuration.skipLocationRequest) {
// Use the base url directly, instead of requesting a location URL from the server
this._locationUrl = Promise.resolve(this.baseUrl);
}
this.localApp = configuration.app;
// Construct a fetcher wrapping the network transport
this.fetcher = new Fetcher({
appId: this.id,
userContext: this as UserContext,
locationUrlContext: this,
fetch: configuration.fetch ?? fetch,
});
// Construct the auth providers
this.emailPasswordAuth = new EmailPasswordAuth(this.fetcher);
// Construct the storage
const baseStorage = configuration.storage || getEnvironment().defaultStorage;
this.storage = new AppStorage(baseStorage, this.id);
this.authenticator = new Authenticator(this.fetcher, baseStorage, () => this.deviceInformation);
// Hydrate the app state from storage
try {
this.hydrate();
} catch (err) {
// The storage was corrupted
this.storage.clear();
// A failed hydration shouldn't throw and break the app experience
// Since this is "just" persisted state that unfortunately got corrupted or partially lost
console.warn("Realm app hydration failed:", err instanceof Error ? err.message : err);
}
}
/**
* Switch user.
* @param nextUser The user or id of the user to switch to.
*/
public switchUser(nextUser: User<FunctionsFactoryType, CustomDataType>): void {
const index = this.users.findIndex((u) => u === nextUser);
if (index === -1) {
throw new Error("The user was never logged into this app");
}
// Remove the user from the stack
const [user] = this.users.splice(index, 1);
// Insert the user in the beginning of the stack
this.users.unshift(user);
}
/**
* Log in a user.
* @param credentials Credentials to use when logging in.
* @param fetchProfile Should the users profile be fetched? (default: true)
* @returns A promise resolving to the newly logged in user.
*/
public async logIn(
credentials: Credentials,
fetchProfile = true,
): Promise<User<FunctionsFactoryType, CustomDataType>> {
if (credentials.reuse) {
// TODO: Consider exposing providerName on "User" and match against that instead?
const existingUser = this.users.find((user) => user.providerType === credentials.providerType);
if (existingUser) {
this.switchUser(existingUser);
// If needed, fetch and set the profile on the user
if (fetchProfile) {
await existingUser.refreshProfile();
}
return existingUser;
}
}
const response = await this.authenticator.authenticate(credentials);
const user = this.createOrUpdateUser(response, credentials.providerType);
// Let's ensure this will be the current user, in case the user object was reused.
this.switchUser(user);
// If needed, fetch and set the profile on the user
if (fetchProfile) {
await user.refreshProfile();
}
// Persist the user id in the storage,
// merging to avoid overriding logins from other apps using the same underlying storage
this.storage.setUserIds(
this.users.map((u) => u.id),
true,
);
// Read out and store the device id from the server
const deviceId = response.deviceId;
if (deviceId && deviceId !== "000000000000000000000000") {
this.storage.set(DEVICE_ID_STORAGE_KEY, deviceId);
}
// Return the user
return user;
}
/**
* @inheritdoc
*/
public async removeUser(user: User<FunctionsFactoryType, CustomDataType>): Promise<void> {
// Remove the user from the list of users
const index = this.users.findIndex((u) => u === user);
if (index === -1) {
throw new Error("The user was never logged into this app");
}
this.users.splice(index, 1);
// Log out the user - this removes access and refresh tokens from storage
await user.logOut();
// Remove the users profile from storage
this.storage.remove(`user(${user.id}):profile`);
// Remove the user from the storage
this.storage.removeUserId(user.id);
}
/**
* @inheritdoc
*/
public async deleteUser(user: User<FunctionsFactoryType, CustomDataType>): Promise<void> {
await this.fetcher.fetchJSON({
method: "DELETE",
path: routes.api().auth().delete().path,
});
await this.removeUser(user);
}
/**
* @inheritdoc
*/
public addListener(): void {
throw new Error("Not yet implemented");
}
/**
* @inheritdoc
*/
public removeListener(): void {
throw new Error("Not yet implemented");
}
/**
* @inheritdoc
*/
public removeAllListeners(): void {
throw new Error("Not yet implemented");
}
/**
* The currently active user (or null if no active users exists).
* @returns the currently active user or null.
*/
public get currentUser(): User<FunctionsFactoryType, CustomDataType> | null {
const activeUsers = this.users.filter((user) => user.state === UserState.Active);
if (activeUsers.length === 0) {
return null;
} else {
// Current user is the top of the stack
return activeUsers[0];
}
}
/**
* All active and logged-out users:
* - First in the list are active users (ordered by most recent call to switchUser or login)
* - Followed by logged out users (also ordered by most recent call to switchUser or login).
* @returns An array of users active or logged out users (current user being the first).
*/
public get allUsers(): Readonly<Record<string, User<FunctionsFactoryType, CustomDataType>>> {
// Returning a freezed copy of the list of users to prevent outside changes
return Object.fromEntries(this.users.map((user) => [user.id, user]));
}
/**
* @returns A promise of the app URL, with the app location resolved.
*/
public get locationUrl(): Promise<string> {
if (!this._locationUrl) {
const path = routes.api().app(this.id).location().path;
this._locationUrl = this.fetcher
.fetchJSON({
method: "GET",
url: this.baseUrl + path,
tokenType: "none",
})
.then((body) => {
if (typeof body !== "object") {
throw new Error("Expected response body be an object");
} else {
return body as Record<string, unknown>;
}
})
.then(({ hostname }) => {
if (typeof hostname !== "string") {
throw new Error("Expected response to contain a 'hostname'");
} else {
return hostname;
}
})
.catch((err) => {
// Reset the location to allow another request to fetch again.
this._locationUrl = null;
throw err;
});
}
return this._locationUrl;
}
/**
* @returns Information about the current device, sent to the server when authenticating.
*/
public get deviceInformation(): DeviceInformation {
const deviceIdStr = this.storage.getDeviceId();
const deviceId =
typeof deviceIdStr === "string" && deviceIdStr !== "000000000000000000000000"
? new ObjectId(deviceIdStr)
: undefined;
return new DeviceInformation({
appId: this.localApp ? this.localApp.name : undefined,
appVersion: this.localApp ? this.localApp.version : undefined,
deviceId,
});
}
/**
* Create (and store) a new user or update an existing user's access and refresh tokens.
* This helps de-duplicating users in the list of users known to the app.
* @param response A response from the Authenticator.
* @param providerType The type of the authentication provider used.
* @returns A new or an existing user.
*/
private createOrUpdateUser(
response: AuthResponse,
providerType: ProviderType,
): User<FunctionsFactoryType, CustomDataType> {
const existingUser = this.users.find((u) => u.id === response.userId);
if (existingUser) {
// Update the users access and refresh tokens
existingUser.accessToken = response.accessToken;
existingUser.refreshToken = response.refreshToken;
return existingUser;
} else {
// Create and store a new user
if (!response.refreshToken) {
throw new Error("No refresh token in response from server");
}
const user = new User<FunctionsFactoryType, CustomDataType>({
app: this,
id: response.userId,
accessToken: response.accessToken,
refreshToken: response.refreshToken,
providerType,
});
this.users.unshift(user);
return user;
}
}
/**
* Restores the state of the app (active and logged-out users) from the storage
*/
private hydrate() {
const userIds = this.storage.getUserIds();
this.users = userIds.map((id) => new User<FunctionsFactoryType, CustomDataType>({ app: this, id }));
}
}