Source code

Revision control

Other Tools

1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
* License, v. 2.0. If a copy of the MPL was not distributed with this
3
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
"use strict";
5
6
const { PromiseUtils } = ChromeUtils.import(
8
);
9
const { CryptoUtils } = ChromeUtils.import(
11
);
12
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
13
const { XPCOMUtils } = ChromeUtils.import(
15
);
16
const { clearTimeout, setTimeout } = ChromeUtils.import(
18
);
19
const { FxAccountsStorageManager } = ChromeUtils.import(
21
);
22
const {
23
ASSERTION_LIFETIME,
24
ASSERTION_USE_PERIOD,
25
CERT_LIFETIME,
26
ERRNO_INVALID_AUTH_TOKEN,
27
ERRNO_INVALID_FXA_ASSERTION,
28
ERROR_AUTH_ERROR,
29
ERROR_INVALID_PARAMETER,
30
ERROR_NO_ACCOUNT,
31
ERROR_OFFLINE,
32
ERROR_TO_GENERAL_ERROR_CLASS,
33
ERROR_UNKNOWN,
34
ERROR_UNVERIFIED_ACCOUNT,
35
FXA_PWDMGR_MEMORY_FIELDS,
36
FXA_PWDMGR_PLAINTEXT_FIELDS,
37
FXA_PWDMGR_REAUTH_WHITELIST,
38
FXA_PWDMGR_SECURE_FIELDS,
39
FX_OAUTH_CLIENT_ID,
40
KEY_LIFETIME,
41
ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
42
ONLOGIN_NOTIFICATION,
43
ONLOGOUT_NOTIFICATION,
44
ONVERIFIED_NOTIFICATION,
45
ON_DEVICE_DISCONNECTED_NOTIFICATION,
46
POLL_SESSION,
47
PREF_ACCOUNT_ROOT,
48
PREF_LAST_FXA_USER,
49
SERVER_ERRNO_TO_ERROR,
50
log,
51
logPII,
52
} = ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
53
54
ChromeUtils.defineModuleGetter(
55
this,
56
"FxAccountsClient",
58
);
59
60
ChromeUtils.defineModuleGetter(
61
this,
62
"FxAccountsOAuthGrantClient",
64
);
65
66
ChromeUtils.defineModuleGetter(
67
this,
68
"FxAccountsConfig",
70
);
71
72
ChromeUtils.defineModuleGetter(
73
this,
74
"jwcrypto",
76
);
77
78
ChromeUtils.defineModuleGetter(
79
this,
80
"FxAccountsCommands",
82
);
83
84
ChromeUtils.defineModuleGetter(
85
this,
86
"FxAccountsDevice",
88
);
89
90
ChromeUtils.defineModuleGetter(
91
this,
92
"FxAccountsKeys",
94
);
95
96
ChromeUtils.defineModuleGetter(
97
this,
98
"FxAccountsProfile",
100
);
101
102
ChromeUtils.defineModuleGetter(
103
this,
104
"FxAccountsTelemetry",
106
);
107
108
XPCOMUtils.defineLazyModuleGetters(this, {
110
});
111
112
XPCOMUtils.defineLazyPreferenceGetter(
113
this,
114
"FXA_ENABLED",
115
"identity.fxaccounts.enabled",
116
true
117
);
118
119
// An AccountState object holds all state related to one specific account.
120
// It is considered "private" to the FxAccounts modules.
121
// Only one AccountState is ever "current" in the FxAccountsInternal object -
122
// whenever a user logs out or logs in, the current AccountState is discarded,
123
// making it impossible for the wrong state or state data to be accidentally
124
// used.
125
// In addition, it has some promise-related helpers to ensure that if an
126
// attempt is made to resolve a promise on a "stale" state (eg, if an
127
// operation starts, but a different user logs in before the operation
128
// completes), the promise will be rejected.
129
// It is intended to be used thusly:
130
// somePromiseBasedFunction: function() {
131
// let currentState = this.currentAccountState;
132
// return someOtherPromiseFunction().then(
133
// data => currentState.resolve(data)
134
// );
135
// }
136
// If the state has changed between the function being called and the promise
137
// being resolved, the .resolve() call will actually be rejected.
138
var AccountState = (this.AccountState = function(storageManager) {
139
this.storageManager = storageManager;
140
this.inFlightTokenRequests = new Map();
141
this.promiseInitialized = this.storageManager
142
.getAccountData()
143
.then(data => {
144
this.oauthTokens = data && data.oauthTokens ? data.oauthTokens : {};
145
})
146
.catch(err => {
147
log.error("Failed to initialize the storage manager", err);
148
// Things are going to fall apart, but not much we can do about it here.
149
});
150
});
151
152
AccountState.prototype = {
153
oauthTokens: null,
154
whenVerifiedDeferred: null,
155
whenKeysReadyDeferred: null,
156
157
// If the storage manager has been nuked then we are no longer current.
158
get isCurrent() {
159
return this.storageManager != null;
160
},
161
162
abort() {
163
if (this.whenVerifiedDeferred) {
164
this.whenVerifiedDeferred.reject(
165
new Error("Verification aborted; Another user signing in")
166
);
167
this.whenVerifiedDeferred = null;
168
}
169
if (this.whenKeysReadyDeferred) {
170
this.whenKeysReadyDeferred.reject(
171
new Error("Verification aborted; Another user signing in")
172
);
173
this.whenKeysReadyDeferred = null;
174
}
175
this.inFlightTokenRequests.clear();
176
return this.signOut();
177
},
178
179
// Clobber all cached data and write that empty data to storage.
180
async signOut() {
181
this.cert = null;
182
this.keyPair = null;
183
this.oauthTokens = null;
184
this.inFlightTokenRequests.clear();
185
186
// Avoid finalizing the storageManager multiple times (ie, .signOut()
187
// followed by .abort())
188
if (!this.storageManager) {
189
return;
190
}
191
const storageManager = this.storageManager;
192
this.storageManager = null;
193
194
await storageManager.deleteAccountData();
195
await storageManager.finalize();
196
},
197
198
// Get user account data. Optionally specify explicit field names to fetch
199
// (and note that if you require an in-memory field you *must* specify the
200
// field name(s).)
201
getUserAccountData(fieldNames = null) {
202
if (!this.isCurrent) {
203
return Promise.reject(new Error("Another user has signed in"));
204
}
205
return this.storageManager.getAccountData(fieldNames).then(result => {
206
return this.resolve(result);
207
});
208
},
209
210
updateUserAccountData(updatedFields) {
211
if (!this.isCurrent) {
212
return Promise.reject(new Error("Another user has signed in"));
213
}
214
return this.storageManager.updateAccountData(updatedFields);
215
},
216
217
resolve(result) {
218
if (!this.isCurrent) {
219
log.info(
220
"An accountState promise was resolved, but was actually rejected" +
221
" due to a different user being signed in. Originally resolved" +
222
" with",
223
result
224
);
225
return Promise.reject(new Error("A different user signed in"));
226
}
227
return Promise.resolve(result);
228
},
229
230
reject(error) {
231
// It could be argued that we should just let it reject with the original
232
// error - but this runs the risk of the error being (eg) a 401, which
233
// might cause the consumer to attempt some remediation and cause other
234
// problems.
235
if (!this.isCurrent) {
236
log.info(
237
"An accountState promise was rejected, but we are ignoring that " +
238
"reason and rejecting it due to a different user being signed in. " +
239
"Originally rejected with",
240
error
241
);
242
return Promise.reject(new Error("A different user signed in"));
243
}
244
return Promise.reject(error);
245
},
246
247
// Abstractions for storage of cached tokens - these are all sync, and don't
248
// handle revocation etc - it's just storage (and the storage itself is async,
249
// but we don't return the storage promises, so it *looks* sync)
250
// These functions are sync simply so we can handle "token races" - when there
251
// are multiple in-flight requests for the same scope, we can detect this
252
// and revoke the redundant token.
253
254
// A preamble for the cache helpers...
255
_cachePreamble() {
256
if (!this.isCurrent) {
257
throw new Error("Another user has signed in");
258
}
259
},
260
261
// Set a cached token. |tokenData| must have a 'token' element, but may also
262
// have additional fields.
263
// The 'get' functions below return the entire |tokenData| value.
264
setCachedToken(scopeArray, tokenData) {
265
this._cachePreamble();
266
if (!tokenData.token) {
267
throw new Error("No token");
268
}
269
let key = getScopeKey(scopeArray);
270
this.oauthTokens[key] = tokenData;
271
// And a background save...
272
this._persistCachedTokens();
273
},
274
275
// Return data for a cached token or null (or throws on bad state etc)
276
getCachedToken(scopeArray) {
277
this._cachePreamble();
278
let key = getScopeKey(scopeArray);
279
let result = this.oauthTokens[key];
280
if (result) {
281
// later we might want to check an expiry date - but we currently
282
// have no such concept, so just return it.
283
log.trace("getCachedToken returning cached token");
284
return result;
285
}
286
return null;
287
},
288
289
// Remove a cached token from the cache. Does *not* revoke it from anywhere.
290
// Returns the entire token entry if found, null otherwise.
291
removeCachedToken(token) {
292
this._cachePreamble();
293
let data = this.oauthTokens;
294
for (let [key, tokenValue] of Object.entries(data)) {
295
if (tokenValue.token == token) {
296
delete data[key];
297
// And a background save...
298
this._persistCachedTokens();
299
return tokenValue;
300
}
301
}
302
return null;
303
},
304
305
// A hook-point for tests. Returns a promise that's ignored in most cases
306
// (notable exceptions are tests and when we explicitly are saving the entire
307
// set of user data.)
308
_persistCachedTokens() {
309
this._cachePreamble();
310
return this.updateUserAccountData({ oauthTokens: this.oauthTokens }).catch(
311
err => {
312
log.error("Failed to update cached tokens", err);
313
}
314
);
315
},
316
};
317
318
/* Given an array of scopes, make a string key by normalizing. */
319
function getScopeKey(scopeArray) {
320
let normalizedScopes = scopeArray.map(item => item.toLowerCase());
321
return normalizedScopes.sort().join("|");
322
}
323
324
function getPropertyDescriptor(obj, prop) {
325
return (
326
Object.getOwnPropertyDescriptor(obj, prop) ||
327
getPropertyDescriptor(Object.getPrototypeOf(obj), prop)
328
);
329
}
330
331
/**
332
* Copies properties from a given object to another object.
333
*
334
* @param from (object)
335
* The object we read property descriptors from.
336
* @param to (object)
337
* The object that we set property descriptors on.
338
* @param thisObj (object)
339
* The object that will be used to .bind() all function properties we find to.
340
* @param keys ([...])
341
* The names of all properties to be copied.
342
*/
343
function copyObjectProperties(from, to, thisObj, keys) {
344
for (let prop of keys) {
345
// Look for the prop in the prototype chain.
346
let desc = getPropertyDescriptor(from, prop);
347
348
if (typeof desc.value == "function") {
349
desc.value = desc.value.bind(thisObj);
350
}
351
352
if (desc.get) {
353
desc.get = desc.get.bind(thisObj);
354
}
355
356
if (desc.set) {
357
desc.set = desc.set.bind(thisObj);
358
}
359
360
Object.defineProperty(to, prop, desc);
361
}
362
}
363
364
/**
365
* The public API.
366
*
367
* TODO - *all* non-underscore stuff here should have sphinx docstrings so
368
* that docs magically appear on https://firefox-source-docs.mozilla.org/
369
* (although |./mach doc| is broken on windows (bug 1232403) and on Linux for
370
* markh (some obscure npm issue he gave up on) - so later...)
371
*/
372
class FxAccounts {
373
constructor(mocks = null) {
374
this._internal = new FxAccountsInternal();
375
if (mocks) {
376
// it's slightly unfortunate that we need to mock the main "internal" object
377
// before calling initialize, primarily so a mock `newAccountState` is in
378
// place before initialize calls it, but we need to initialize the
379
// "sub-object" mocks after. This can probably be fixed, but whatever...
380
copyObjectProperties(
381
mocks,
382
this._internal,
383
this._internal,
384
Object.keys(mocks).filter(key => !["device", "commands"].includes(key))
385
);
386
}
387
this._internal.initialize();
388
// allow mocking our "sub-objects" too.
389
if (mocks) {
390
for (let subobject of [
391
"currentAccountState",
392
"keys",
393
"fxaPushService",
394
"device",
395
"commands",
396
]) {
397
if (typeof mocks[subobject] == "object") {
398
copyObjectProperties(
399
mocks[subobject],
400
this._internal[subobject],
401
this._internal[subobject],
402
Object.keys(mocks[subobject])
403
);
404
}
405
}
406
}
407
}
408
409
get commands() {
410
return this._internal.commands;
411
}
412
413
static get config() {
414
return FxAccountsConfig;
415
}
416
417
get device() {
418
return this._internal.device;
419
}
420
421
get keys() {
422
return this._internal.keys;
423
}
424
425
get telemetry() {
426
return this._internal.telemetry;
427
}
428
429
_withCurrentAccountState(func) {
430
return this._internal.withCurrentAccountState(func);
431
}
432
433
_withVerifiedAccountState(func) {
434
return this._internal.withVerifiedAccountState(func);
435
}
436
437
_withSessionToken(func, mustBeVerified = true) {
438
return this._internal.withSessionToken(func, mustBeVerified);
439
}
440
441
/**
442
* Returns an array listing all the OAuth clients connected to the
443
* authenticated user's account. This includes browsers and web sessions - no
444
* filtering is done of the set returned by the FxA server.
445
*
446
* @typedef {Object} AttachedClient
447
* @property {String} id - OAuth `client_id` of the client.
448
* @property {Number} lastAccessedDaysAgo - How many days ago the client last
449
* accessed the FxA server APIs.
450
*
451
* @returns {Array.<AttachedClient>} A list of attached clients.
452
*/
453
async listAttachedOAuthClients() {
454
// We expose last accessed times in 'days ago'
455
const ONE_DAY = 24 * 60 * 60 * 1000;
456
457
return this._withSessionToken(async sessionToken => {
458
const attachedClients = await this._internal.fxAccountsClient.attachedClients(
459
sessionToken
460
);
461
// We should use the server timestamp here - bug 1595635
462
let now = Date.now();
463
return attachedClients.map(client => {
464
const daysAgo = client.lastAccessTime
465
? Math.max(Math.floor((now - client.lastAccessTime) / ONE_DAY), 0)
466
: null;
467
return {
468
id: client.clientId,
469
lastAccessedDaysAgo: daysAgo,
470
};
471
});
472
});
473
}
474
475
/**
476
* Retrieves an OAuth authorization code.
477
*
478
* @param {Object} options
479
* @param options.client_id
480
* @param options.state
481
* @param options.scope
482
* @param options.access_type
483
* @param options.code_challenge_method
484
* @param options.code_challenge
485
* @param [options.keys_jwe]
486
* @returns {Promise<Object>} Object containing "code" and "state" properties.
487
*/
488
authorizeOAuthCode(options) {
489
return this._withVerifiedAccountState(async state => {
490
const { sessionToken } = await state.getUserAccountData(["sessionToken"]);
491
const params = { ...options };
492
if (params.keys_jwk) {
493
const jwk = JSON.parse(
494
new TextDecoder().decode(
495
ChromeUtils.base64URLDecode(params.keys_jwk, { padding: "reject" })
496
)
497
);
498
params.keys_jwe = await this._internal.createKeysJWE(
499
params.client_id,
500
params.scope,
501
jwk
502
);
503
delete params.keys_jwk;
504
}
505
try {
506
return await this._internal.fxAccountsClient.oauthAuthorize(
507
sessionToken,
508
params
509
);
510
} catch (err) {
511
throw this._internal._errorToErrorClass(err);
512
}
513
});
514
}
515
516
/**
517
* Get an OAuth token for the user
518
*
519
* @param options
520
* {
521
* scope: (string/array) the oauth scope(s) being requested. As a
522
* convenience, you may pass a string if only one scope is
523
* required, or an array of strings if multiple are needed.
524
* }
525
*
526
* @return Promise.<string | Error>
527
* The promise resolves the oauth token as a string or rejects with
528
* an error object ({error: ERROR, details: {}}) of the following:
529
* INVALID_PARAMETER
530
* NO_ACCOUNT
531
* UNVERIFIED_ACCOUNT
532
* NETWORK_ERROR
533
* AUTH_ERROR
534
* UNKNOWN_ERROR
535
*/
536
async getOAuthToken(options = {}) {
537
try {
538
return await this._internal.getOAuthToken(options);
539
} catch (err) {
540
throw this._internal._errorToErrorClass(err);
541
}
542
}
543
544
/**
545
* Remove an OAuth token from the token cache. Callers should call this
546
* after they determine a token is invalid, so a new token will be fetched
547
* on the next call to getOAuthToken().
548
*
549
* @param options
550
* {
551
* token: (string) A previously fetched token.
552
* }
553
* @return Promise.<undefined> This function will always resolve, even if
554
* an unknown token is passed.
555
*/
556
removeCachedOAuthToken(options) {
557
return this._internal.removeCachedOAuthToken(options);
558
}
559
560
/**
561
* Get details about the user currently signed in to Firefox Accounts.
562
*
563
* @return Promise
564
* The promise resolves to the credentials object of the signed-in user:
565
* {
566
* email: String: The user's email address
567
* uid: String: The user's unique id
568
* verified: Boolean: email verification status
569
* displayName: String or null if not known.
570
* avatar: URL of the avatar for the user. May be the default
571
* avatar, or null in edge-cases (eg, if there's an account
572
* issue, etc
573
* avatarDefault: boolean - whether `avatar` is specific to the user
574
* or the default avatar.
575
* }
576
*
577
* or null if no user is signed in. This function never fails except
578
* in pathological cases (eg, file-system errors, etc)
579
*/
580
getSignedInUser() {
581
// Note we don't return the session token, but use it to see if we
582
// should fetch the profile.
583
const ACCT_DATA_FIELDS = ["email", "uid", "verified", "sessionToken"];
584
const PROFILE_FIELDS = ["displayName", "avatar", "avatarDefault"];
585
return this._withCurrentAccountState(async currentState => {
586
const data = await currentState.getUserAccountData(ACCT_DATA_FIELDS);
587
if (!data) {
588
return null;
589
}
590
if (!FXA_ENABLED) {
591
await this.signOut();
592
return null;
593
}
594
if (!this._internal.isUserEmailVerified(data)) {
595
// If the email is not verified, start polling for verification,
596
// but return null right away. We don't want to return a promise
597
// that might not be fulfilled for a long time.
598
this._internal.startVerifiedCheck(data);
599
}
600
601
let profileData = null;
602
if (data.sessionToken) {
603
delete data.sessionToken;
604
try {
605
profileData = await this._internal.profile.getProfile();
606
} catch (error) {
607
log.error("Could not retrieve profile data", error);
608
}
609
}
610
for (let field of PROFILE_FIELDS) {
611
data[field] = profileData ? profileData[field] : null;
612
}
613
// and email is a special case - if we have profile data we prefer the
614
// email from that, as the email we stored for the account itself might
615
// not have been updated if the email changed since the user signed in.
616
if (profileData && profileData.email) {
617
data.email = profileData.email;
618
}
619
return data;
620
});
621
}
622
623
/**
624
* Checks the status of the account. Resolves with Promise<boolean>, where
625
* true indicates the account status is OK and false indicates there's some
626
* issue with the account - either that there's no user currently signed in,
627
* the entire account has been deleted (in which case there will be no user
628
* signed in after this call returns), or that the user must reauthenticate (in
629
* which case `this.hasLocalSession()` will return `false` after this call
630
* returns).
631
*
632
* Typically used when some external code which uses, for example, oauth tokens
633
* received a 401 error using the token, or that this external code has some
634
* other reason to believe the account status may be bad. Note that this will
635
* be called automatically in many cases - for example, if calls to fetch the
636
* profile, or fetch keys, etc return a 401, there's no need to call this
637
* function.
638
*
639
* Because this hits the server, you should only call this method when you have
640
* good reason to believe the session very recently became invalid (eg, because
641
* you saw an auth related exception from a remote service.)
642
*/
643
checkAccountStatus() {
644
// Note that we don't use _withCurrentAccountState here because that will
645
// cause an exception to be thrown if we end up signing out due to the
646
// account not existing, which isn't what we want here.
647
let state = this._internal.currentAccountState;
648
return this._internal.checkAccountStatus(state);
649
}
650
651
/**
652
* Checks if we have a valid local session state for the current account.
653
*
654
* @return Promise
655
* Resolves with a boolean, with true indicating that we appear to
656
* have a valid local session, or false if we need to reauthenticate
657
* with the content server to obtain one.
658
* Note that this only checks local state, although typically that's
659
* OK, because we drop the local session information whenever we detect
660
* we are in this state. However, see checkAccountStatus() for a way to
661
* check the account and session status with the server, which can be
662
* considered the canonical, albiet expensive, way to determine the
663
* status of the account.
664
*/
665
hasLocalSession() {
666
return this._withCurrentAccountState(async state => {
667
let data = await state.getUserAccountData(["sessionToken"]);
668
return !!(data && data.sessionToken);
669
});
670
}
671
672
/**
673
* Send a message to a set of devices in the same account
674
*
675
* @param deviceIds: (null/string/array) The device IDs to send the message to.
676
* If null, will be sent to all devices.
677
*
678
* @param excludedIds: (null/string/array) If deviceIds is null, this may
679
* list device IDs which should not receive the message.
680
*
681
* @param payload: (object) The payload, which will be JSON.stringified.
682
*
683
* @param TTL: How long the message should be retained before it is discarded.
684
*/
685
// XXX - used only by sync to tell other devices that the clients collection
686
// has changed so they should sync asap. The API here is somewhat vague (ie,
687
// "an object"), but to be useful across devices, the payload really needs
688
// formalizing. We should try and do something better here.
689
notifyDevices(deviceIds, excludedIds, payload, TTL) {
690
return this._internal.notifyDevices(deviceIds, excludedIds, payload, TTL);
691
}
692
693
/**
694
* Resend the verification email for the currently signed-in user.
695
*
696
*/
697
resendVerificationEmail() {
698
return this._withSessionToken((token, currentState) => {
699
this._internal.startPollEmailStatus(currentState, token, "start");
700
return this._internal.fxAccountsClient.resendVerificationEmail(token);
701
}, false);
702
}
703
704
async signOut(localOnly) {
705
// Note that we do not use _withCurrentAccountState here, otherwise we
706
// end up with an exception due to the user signing out before the call is
707
// complete - but that's the entire point of this method :)
708
return this._internal.signOut(localOnly);
709
}
710
711
// XXX - we should consider killing this - the only reason it is public is
712
// so that sync can change it when it notices the device name being changed,
713
// and that could probably be replaced with a pref observer.
714
updateDeviceRegistration() {
715
return this._withCurrentAccountState(_ => {
716
return this._internal.updateDeviceRegistration();
717
});
718
}
719
720
// we should try and kill this too.
721
whenVerified(data) {
722
return this._withCurrentAccountState(_ => {
723
return this._internal.whenVerified(data);
724
});
725
}
726
}
727
728
var FxAccountsInternal = function() {};
729
730
/**
731
* The internal API's prototype.
732
*/
733
FxAccountsInternal.prototype = {
734
// Make a local copy of this constant so we can mock it in testing
735
POLL_SESSION,
736
737
// The timeout (in ms) we use to poll for a verified mail for the first
738
// VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD minutes if the user has
739
// logged-in in this session.
740
VERIFICATION_POLL_TIMEOUT_INITIAL: 60000, // 1 minute.
741
// All the other cases (> 5 min, on restart etc).
742
VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 5 * 60000, // 5 minutes.
743
// After X minutes, the polling will slow down to _SUBSEQUENT if we have
744
// logged-in in this session.
745
VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD: 5,
746
747
_fxAccountsClient: null,
748
749
// All significant initialization should be done in this initialize() method
750
// to help with our mocking story.
751
initialize() {
752
XPCOMUtils.defineLazyGetter(this, "fxaPushService", function() {
753
return Cc["@mozilla.org/fxaccounts/push;1"].getService(
754
Ci.nsISupports
755
).wrappedJSObject;
756
});
757
758
this.keys = new FxAccountsKeys(this);
759
760
if (!this.observerPreloads) {
761
// A registry of promise-returning functions that `notifyObservers` should
762
// call before sending notifications. Primarily used so parts of Firefox
763
// which have yet to load for performance reasons can be force-loaded, and
764
// thus not miss notifications.
765
this.observerPreloads = [
766
// Sync
767
() => {
768
let scope = {};
769
ChromeUtils.import("resource://services-sync/main.js", scope);
770
return scope.Weave.Service.promiseInitialized;
771
},
772
];
773
}
774
775
this.currentTimer = null;
776
// This object holds details about, and storage for, the current user. It
777
// is replaced when a different user signs in. Instead of using it directly,
778
// you should try and use `withCurrentAccountState`.
779
this.currentAccountState = this.newAccountState();
780
},
781
782
async withCurrentAccountState(func) {
783
const state = this.currentAccountState;
784
let result;
785
try {
786
result = await func(state);
787
} catch (ex) {
788
return state.reject(ex);
789
}
790
return state.resolve(result);
791
},
792
793
async withVerifiedAccountState(func) {
794
return this.withCurrentAccountState(async state => {
795
let data = await state.getUserAccountData();
796
if (!data) {
797
// No signed-in user
798
throw this._error(ERROR_NO_ACCOUNT);
799
}
800
801
if (!this.isUserEmailVerified(data)) {
802
// Signed-in user has not verified email
803
throw this._error(ERROR_UNVERIFIED_ACCOUNT);
804
}
805
return func(state);
806
});
807
},
808
809
async withSessionToken(func, mustBeVerified = true) {
810
const state = this.currentAccountState;
811
let data = await state.getUserAccountData();
812
if (!data) {
813
// No signed-in user
814
throw this._error(ERROR_NO_ACCOUNT);
815
}
816
817
if (mustBeVerified && !this.isUserEmailVerified(data)) {
818
// Signed-in user has not verified email
819
throw this._error(ERROR_UNVERIFIED_ACCOUNT);
820
}
821
822
if (!data.sessionToken) {
823
throw this._error(ERROR_AUTH_ERROR, "no session token");
824
}
825
try {
826
// Anyone who needs the session token is going to send it to the server,
827
// so there's a chance we'll see an auth related error - so handle that
828
// here rather than requiring each caller to remember to.
829
let result = await func(data.sessionToken, state);
830
return state.resolve(result);
831
} catch (err) {
832
return this._handleTokenError(err);
833
}
834
},
835
836
get fxAccountsClient() {
837
if (!this._fxAccountsClient) {
838
this._fxAccountsClient = new FxAccountsClient();
839
}
840
return this._fxAccountsClient;
841
},
842
843
get fxAccountsOAuthGrantClient() {
844
if (!this._fxAccountsOAuthGrantClient) {
845
this._fxAccountsOAuthGrantClient = new FxAccountsOAuthGrantClient({
846
client_id: FX_OAUTH_CLIENT_ID,
847
});
848
}
849
return this._fxAccountsOAuthGrantClient;
850
},
851
852
// The profile object used to fetch the actual user profile.
853
_profile: null,
854
get profile() {
855
if (!this._profile) {
856
let profileServerUrl = Services.urlFormatter.formatURLPref(
857
"identity.fxaccounts.remote.profile.uri"
858
);
859
this._profile = new FxAccountsProfile({
860
fxa: this,
861
profileServerUrl,
862
});
863
}
864
return this._profile;
865
},
866
867
_commands: null,
868
get commands() {
869
if (!this._commands) {
870
this._commands = new FxAccountsCommands(this);
871
}
872
return this._commands;
873
},
874
875
_device: null,
876
get device() {
877
if (!this._device) {
878
this._device = new FxAccountsDevice(this);
879
}
880
return this._device;
881
},
882
883
_telemetry: null,
884
get telemetry() {
885
if (!this._telemetry) {
886
this._telemetry = new FxAccountsTelemetry(this);
887
}
888
return this._telemetry;
889
},
890
891
// A hook-point for tests who may want a mocked AccountState or mocked storage.
892
newAccountState(credentials) {
893
let storage = new FxAccountsStorageManager();
894
storage.initialize(credentials);
895
return new AccountState(storage);
896
},
897
898
notifyDevices(deviceIds, excludedIds, payload, TTL) {
899
if (typeof deviceIds == "string") {
900
deviceIds = [deviceIds];
901
}
902
return this.withSessionToken(sessionToken => {
903
return this.fxAccountsClient.notifyDevices(
904
sessionToken,
905
deviceIds,
906
excludedIds,
907
payload,
908
TTL
909
);
910
});
911
},
912
913
/**
914
* Return the current time in milliseconds as an integer. Allows tests to
915
* manipulate the date to simulate certificate expiration.
916
*/
917
now() {
918
return this.fxAccountsClient.now();
919
},
920
921
/**
922
* Return clock offset in milliseconds, as reported by the fxAccountsClient.
923
* This can be overridden for testing.
924
*
925
* The offset is the number of milliseconds that must be added to the client
926
* clock to make it equal to the server clock. For example, if the client is
927
* five minutes ahead of the server, the localtimeOffsetMsec will be -300000.
928
*/
929
get localtimeOffsetMsec() {
930
return this.fxAccountsClient.localtimeOffsetMsec;
931
},
932
933
/**
934
* Ask the server whether the user's email has been verified
935
*/
936
checkEmailStatus: function checkEmailStatus(sessionToken, options = {}) {
937
if (!sessionToken) {
938
return Promise.reject(
939
new Error("checkEmailStatus called without a session token")
940
);
941
}
942
return this.fxAccountsClient
943
.recoveryEmailStatus(sessionToken, options)
944
.catch(error => this._handleTokenError(error));
945
},
946
947
// set() makes sure that polling is happening, if necessary.
948
// get() does not wait for verification, and returns an object even if
949
// unverified. The caller of get() must check .verified .
950
// The "fxaccounts:onverified" event will fire only when the verified
951
// state goes from false to true, so callers must register their observer
952
// and then call get(). In particular, it will not fire when the account
953
// was found to be verified in a previous boot: if our stored state says
954
// the account is verified, the event will never fire. So callers must do:
955
// register notification observer (go)
956
// userdata = get()
957
// if (userdata.verified()) {go()}
958
959
/**
960
* Set the current user signed in to Firefox Accounts.
961
*
962
* @param credentials
963
* The credentials object obtained by logging in or creating
964
* an account on the FxA server:
965
* {
966
* authAt: The time (seconds since epoch) that this record was
967
* authenticated
968
* email: The users email address
969
* keyFetchToken: a keyFetchToken which has not yet been used
970
* sessionToken: Session for the FxA server
971
* uid: The user's unique id
972
* unwrapBKey: used to unwrap kB, derived locally from the
973
* password (not revealed to the FxA server)
974
* verified: true/false
975
* }
976
* @return Promise
977
* The promise resolves to null when the data is saved
978
* successfully and is rejected on error.
979
*/
980
async setSignedInUser(credentials) {
981
if (!FXA_ENABLED) {
982
throw new Error("Cannot call setSignedInUser when FxA is disabled.");
983
}
984
Preferences.resetBranch(PREF_ACCOUNT_ROOT);
985
log.debug("setSignedInUser - aborting any existing flows");
986
const signedInUser = await this.currentAccountState.getUserAccountData();
987
if (signedInUser) {
988
await this._signOutServer(
989
signedInUser.sessionToken,
990
signedInUser.oauthTokens
991
);
992
}
993
await this.abortExistingFlow();
994
let currentAccountState = (this.currentAccountState = this.newAccountState(
995
Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object.
996
));
997
// This promise waits for storage, but not for verification.
998
// We're telling the caller that this is durable now (although is that
999
// really something we should commit to? Why not let the write happen in
1000
// the background? Already does for updateAccountData ;)
1001
await currentAccountState.promiseInitialized;
1002
// Starting point for polling if new user
1003
if (!this.isUserEmailVerified(credentials)) {
1004
this.startVerifiedCheck(credentials);
1005
}
1006
await this.notifyObservers(ONLOGIN_NOTIFICATION);
1007
await this.updateDeviceRegistration();
1008
return currentAccountState.resolve();
1009
},
1010
1011
/**
1012
* Update account data for the currently signed in user.
1013
*
1014
* @param credentials
1015
* The credentials object containing the fields to be updated.
1016
* This object must contain the |uid| field and it must
1017
* match the currently signed in user.
1018
*/
1019
updateUserAccountData(credentials) {
1020
log.debug(
1021
"updateUserAccountData called with fields",
1022
Object.keys(credentials)
1023
);
1024
if (logPII) {
1025
log.debug("updateUserAccountData called with data", credentials);
1026
}
1027
let currentAccountState = this.currentAccountState;
1028
return currentAccountState.promiseInitialized
1029
.then(() => {
1030
return currentAccountState.getUserAccountData(["uid"]);
1031
})
1032
.then(existing => {
1033
if (existing.uid != credentials.uid) {
1034
throw new Error(
1035
"The specified credentials aren't for the current user"
1036
);
1037
}
1038
// We need to nuke uid as storage will complain if we try and
1039
// update it (even when the value is the same)
1040
credentials = Cu.cloneInto(credentials, {}); // clone it first
1041
delete credentials.uid;
1042
return currentAccountState.updateUserAccountData(credentials);
1043
});
1044
},
1045
1046
/**
1047
* returns a promise that fires with the assertion. Throws if there is no
1048
* verified signed-in user or no local sessionToken.
1049
*/
1050
getAssertion: function getAssertion(audience) {
1051
return this._getAssertion(audience);
1052
},
1053
1054
// getAssertion() is "public" so screws with our mock story. This
1055
// implementation method *can* be (and is) mocked by tests.
1056
_getAssertion(audience) {
1057
log.debug("enter getAssertion()");
1058
return this.withSessionToken(async (_, currentState) => {
1059
let { keyPair, certificate } = await this.getKeypairAndCertificate(
1060
currentState
1061
);
1062
return this.getAssertionFromCert(
1063
await currentState.getUserAccountData(),
1064
keyPair,
1065
certificate,
1066
audience
1067
);
1068
});
1069
},
1070
1071
/*
1072
* Reset state such that any previous flow is canceled.
1073
*/
1074
abortExistingFlow() {
1075
if (this.currentTimer) {
1076
log.debug("Polling aborted; Another user signing in");
1077
clearTimeout(this.currentTimer);
1078
this.currentTimer = 0;
1079
}
1080
if (this._profile) {
1081
this._profile.tearDown();
1082
this._profile = null;
1083
}
1084
if (this._commands) {
1085
this._commands = null;
1086
}
1087
if (this._device) {
1088
this._device.reset();
1089
}
1090
// We "abort" the accountState and assume our caller is about to throw it
1091
// away and replace it with a new one.
1092
return this.currentAccountState.abort();
1093
},
1094
1095
async checkVerificationStatus() {
1096
log.trace("checkVerificationStatus");
1097
let state = this.currentAccountState;
1098
let data = await state.getUserAccountData();
1099
if (!data) {
1100
log.trace("checkVerificationStatus - no user data");
1101
return null;
1102
}
1103
1104
// Always check the verification status, even if the local state indicates
1105
// we're already verified. If the user changed their password, the check
1106
// will fail, and we'll enter the reauth state.
1107
log.trace("checkVerificationStatus - forcing verification status check");
1108
return this.startPollEmailStatus(state, data.sessionToken, "push");
1109
},
1110
1111
_destroyOAuthToken(tokenData) {
1112
return this.fxAccountsClient.oauthDestroy(
1113
FX_OAUTH_CLIENT_ID,
1114
tokenData.token
1115
);
1116
},
1117
1118
_destroyAllOAuthTokens(tokenInfos) {
1119
if (!tokenInfos) {
1120
return Promise.resolve();
1121
}
1122
// let's just destroy them all in parallel...
1123
let promises = [];
1124
for (let tokenInfo of Object.values(tokenInfos)) {
1125
promises.push(this._destroyOAuthToken(tokenInfo));
1126
}
1127
return Promise.all(promises);
1128
},
1129
1130
async signOut(localOnly) {
1131
let sessionToken;
1132
let tokensToRevoke;
1133
const data = await this.currentAccountState.getUserAccountData();
1134
// Save the sessionToken, tokens before resetting them in _signOutLocal().
1135
if (data) {
1136
sessionToken = data.sessionToken;
1137
tokensToRevoke = data.oauthTokens;
1138
}
1139
await this._signOutLocal();
1140
if (!localOnly) {
1141
// Do this in the background so *any* slow request won't
1142
// block the local sign out.
1143
Services.tm.dispatchToMainThread(async () => {
1144
await this._signOutServer(sessionToken, tokensToRevoke);
1145
FxAccountsConfig.resetConfigURLs();
1146
this.notifyObservers("testhelper-fxa-signout-complete");
1147
});
1148
} else {
1149
// We want to do this either way -- but if we're signing out remotely we
1150
// need to wait until we destroy the oauth tokens if we want that to succeed.
1151
FxAccountsConfig.resetConfigURLs();
1152
}
1153
return this.notifyObservers(ONLOGOUT_NOTIFICATION);
1154
},
1155
1156
async _signOutLocal() {
1157
Preferences.resetBranch(PREF_ACCOUNT_ROOT);
1158
await this.currentAccountState.signOut();
1159
// this "aborts" this.currentAccountState but doesn't make a new one.
1160
await this.abortExistingFlow();
1161
this.currentAccountState = this.newAccountState();
1162
return this.currentAccountState.promiseInitialized;
1163
},
1164
1165
async _signOutServer(sessionToken, tokensToRevoke) {
1166
log.debug("Unsubscribing from FxA push.");
1167
try {
1168
await this.fxaPushService.unsubscribe();
1169
} catch (err) {
1170
log.error("Could not unsubscribe from push.", err);
1171
}
1172
if (sessionToken) {
1173
log.debug("Destroying session and device.");
1174
try {
1175
await this.fxAccountsClient.signOut(sessionToken, { service: "sync" });
1176
} catch (err) {
1177
log.error("Error during remote sign out of Firefox Accounts", err);
1178
}
1179
} else {
1180
log.warn("Missing session token; skipping remote sign out");
1181
}
1182
log.debug("Destroying all OAuth tokens.");
1183
try {
1184
await this._destroyAllOAuthTokens(tokensToRevoke);
1185
} catch (err) {
1186
log.error("Error during destruction of oauth tokens during signout", err);
1187
}
1188
},
1189
1190
async getAssertionFromCert(data, keyPair, cert, audience) {
1191
log.debug("getAssertionFromCert");
1192
let options = {
1193
duration: ASSERTION_LIFETIME,
1194
localtimeOffsetMsec: this.localtimeOffsetMsec,
1195
now: this.now(),
1196
};
1197
let currentState = this.currentAccountState;
1198
// "audience" should look like "http://123done.org".
1199
// The generated assertion will expire in two minutes.
1200
let assertion = await new Promise((resolve, reject) => {
1201
jwcrypto.generateAssertion(
1202
cert,
1203
keyPair,
1204
audience,
1205
options,
1206
(err, signed) => {
1207
if (err) {
1208
log.error("getAssertionFromCert: " + err);
1209
reject(err);
1210
} else {
1211
log.debug("getAssertionFromCert returning signed: " + !!signed);
1212
if (logPII) {
1213
log.debug("getAssertionFromCert returning signed: " + signed);
1214
}
1215
resolve(signed);
1216
}
1217
}
1218
);
1219
});
1220
return currentState.resolve(assertion);
1221
},
1222
1223
getCertificateSigned(sessionToken, serializedPublicKey, lifetime) {
1224
log.debug(
1225
"getCertificateSigned: " + !!sessionToken + " " + !!serializedPublicKey
1226
);
1227
if (logPII) {
1228
log.debug(
1229
"getCertificateSigned: " + sessionToken + " " + serializedPublicKey
1230
);
1231
}
1232
return this.fxAccountsClient.signCertificate(
1233
sessionToken,
1234
JSON.parse(serializedPublicKey),
1235
lifetime
1236
);
1237
},
1238
1239
/**
1240
* returns a promise that fires with {keyPair, certificate}.
1241
*/
1242
async getKeypairAndCertificate(currentState) {
1243
// If the debugging pref to ignore cached authentication credentials is set for Sync,
1244
// then don't use any cached key pair/certificate, i.e., generate a new
1245
// one and get it signed.
1246
// The purpose of this pref is to expedite any auth errors as the result of a
1247
// expired or revoked FxA session token, e.g., from resetting or changing the FxA
1248
// password.
1249
let ignoreCachedAuthCredentials = Services.prefs.getBoolPref(
1250
"services.sync.debug.ignoreCachedAuthCredentials",
1251
false
1252
);
1253
let mustBeValidUntil = this.now() + ASSERTION_USE_PERIOD;
1254
let accountData = await currentState.getUserAccountData([
1255
"cert",
1256
"keyPair",
1257
"sessionToken",
1258
]);
1259
1260
let keyPairValid =
1261
!ignoreCachedAuthCredentials &&
1262
accountData.keyPair &&
1263
accountData.keyPair.validUntil > mustBeValidUntil;
1264
let certValid =
1265
!ignoreCachedAuthCredentials &&
1266
accountData.cert &&
1267
accountData.cert.validUntil > mustBeValidUntil;
1268
// TODO: get the lifetime from the cert's .exp field
1269
if (keyPairValid && certValid) {
1270
log.debug(
1271
"getKeypairAndCertificate: already have keyPair and certificate"
1272
);
1273
return {
1274
keyPair: accountData.keyPair.rawKeyPair,
1275
certificate: accountData.cert.rawCert,
1276
};
1277
}
1278
// We are definately going to generate a new cert, either because it has
1279
// already expired, or the keyPair has - and a new keyPair means we must
1280
// generate a new cert.
1281
1282
// A keyPair has a longer lifetime than a cert, so it's possible we will
1283
// have a valid keypair but an expired cert, which means we can skip
1284
// keypair generation.
1285
// Either way, the cert will require hitting the network, so bail now if
1286
// we know that's going to fail.
1287
if (Services.io.offline) {
1288
throw new Error(ERROR_OFFLINE);
1289
}
1290
1291
let keyPair;
1292
if (keyPairValid) {
1293
keyPair = accountData.keyPair;
1294
} else {
1295
let keyWillBeValidUntil = this.now() + KEY_LIFETIME;
1296
keyPair = await new Promise((resolve, reject) => {
1297
jwcrypto.generateKeyPair("DS160", (err, kp) => {
1298
if (err) {
1299
reject(err);
1300
return;
1301
}
1302
log.debug("got keyPair");
1303
resolve({
1304
rawKeyPair: kp,
1305
validUntil: keyWillBeValidUntil,
1306
});
1307
});
1308
});
1309
}
1310
1311
// and generate the cert.
1312
let certWillBeValidUntil = this.now() + CERT_LIFETIME;
1313
let certificate = await this.getCertificateSigned(
1314
accountData.sessionToken,
1315
keyPair.rawKeyPair.serializedPublicKey,
1316
CERT_LIFETIME
1317
);
1318
log.debug("getCertificate got a new one: " + !!certificate);
1319
if (certificate) {
1320
// Cache both keypair and cert.
1321
let toUpdate = {
1322
keyPair,
1323
cert: {
1324
rawCert: certificate,
1325
validUntil: certWillBeValidUntil,
1326
},
1327
};
1328
await currentState.updateUserAccountData(toUpdate);
1329
}
1330
return {
1331
keyPair: keyPair.rawKeyPair,
1332
certificate,
1333
};
1334
},
1335
1336
getUserAccountData(fieldNames = null) {
1337
return this.currentAccountState.getUserAccountData(fieldNames);
1338
},
1339
1340
isUserEmailVerified: function isUserEmailVerified(data) {
1341
return !!(data && data.verified);
1342
},
1343
1344
/**
1345
* Setup for and if necessary do email verification polling.
1346
*/
1347
loadAndPoll() {
1348
let currentState = this.currentAccountState;
1349
return currentState.getUserAccountData().then(data => {
1350
if (data) {
1351
if (!this.isUserEmailVerified(data)) {
1352
this.startPollEmailStatus(
1353
currentState,
1354
data.sessionToken,
1355
"browser-startup"
1356
);
1357
}
1358
}
1359
return data;
1360
});
1361
},
1362
1363
startVerifiedCheck(data) {
1364
log.debug("startVerifiedCheck", data && data.verified);
1365
if (logPII) {
1366
log.debug("startVerifiedCheck with user data", data);
1367
}
1368
1369
// Get us to the verified state. This returns a promise that will fire when
1370
// verification is complete.
1371
1372
// The callers of startVerifiedCheck never consume a returned promise (ie,
1373
// this is simply kicking off a background fetch) so we must add a rejection
1374
// handler to avoid runtime warnings about the rejection not being handled.
1375
this.whenVerified(data).catch(err =>
1376
log.info("startVerifiedCheck promise was rejected: " + err)
1377
);
1378
},
1379
1380
whenVerified(data) {
1381
let currentState = this.currentAccountState;
1382
if (data.verified) {
1383
log.debug("already verified");
1384
return currentState.resolve(data);
1385
}
1386
if (!currentState.whenVerifiedDeferred) {
1387
log.debug("whenVerified promise starts polling for verified email");
1388
this.startPollEmailStatus(currentState, data.sessionToken, "start");
1389
}
1390
return currentState.whenVerifiedDeferred.promise.then(result =>
1391
currentState.resolve(result)
1392
);
1393
},
1394
1395
async notifyObservers(topic, data) {
1396
for (let f of this.observerPreloads) {
1397
try {
1398
await f();
1399
} catch (O_o) {}
1400
}
1401
log.debug("Notifying observers of " + topic);
1402
Services.obs.notifyObservers(null, topic, data);
1403
},
1404
1405
startPollEmailStatus(currentState, sessionToken, why) {
1406
log.debug("entering startPollEmailStatus: " + why);
1407
// If we were already polling, stop and start again. This could happen
1408
// if the user requested the verification email to be resent while we
1409
// were already polling for receipt of an earlier email.
1410
if (this.currentTimer) {
1411
log.debug(
1412
"startPollEmailStatus starting while existing timer is running"
1413
);
1414
clearTimeout(this.currentTimer);
1415
this.currentTimer = null;
1416
}
1417
1418
this.pollStartDate = Date.now();
1419
if (!currentState.whenVerifiedDeferred) {
1420
currentState.whenVerifiedDeferred = PromiseUtils.defer();
1421
// This deferred might not end up with any handlers (eg, if sync
1422
// is yet to start up.) This might cause "A promise chain failed to
1423
// handle a rejection" messages, so add an error handler directly
1424
// on the promise to log the error.
1425
currentState.whenVerifiedDeferred.promise.then(
1426
() => {
1427
log.info("the user became verified");
1428
// We are now ready for business. This should only be invoked once
1429
// per setSignedInUser(), regardless of whether we've rebooted since
1430
// setSignedInUser() was called.
1431
this.notifyObservers(ONVERIFIED_NOTIFICATION);
1432
},
1433
err => {
1434
log.info("the wait for user verification was stopped: " + err);
1435
}
1436
);
1437
}
1438
return this.pollEmailStatus(currentState, sessionToken, why);
1439
},
1440
1441
// We return a promise for testing only. Other callers can ignore this,
1442
// since verification polling continues in the background.
1443
async pollEmailStatus(currentState, sessionToken, why) {
1444
log.debug("entering pollEmailStatus: " + why);
1445
let nextPollMs;
1446
try {
1447
const response = await this.checkEmailStatus(sessionToken, {
1448
reason: why,
1449
});
1450
log.debug("checkEmailStatus -> " + JSON.stringify(response));
1451
if (response && response.verified) {
1452
await this.onPollEmailSuccess(currentState);
1453
return;
1454
}
1455
} catch (error) {
1456
if (error && error.code && error.code == 401) {
1457
let error = new Error("Verification status check failed");
1458
this._rejectWhenVerified(currentState, error);
1459
return;
1460
}
1461
if (error && error.retryAfter) {
1462
// If the server told us to back off, back off the requested amount.
1463
nextPollMs = (error.retryAfter + 3) * 1000;
1464
log.warn(
1465
`the server rejected our email status check and told us to try again in ${nextPollMs}ms`
1466
);
1467
} else {
1468
log.error(`checkEmailStatus failed to poll`, error);
1469
}
1470
}
1471
if (why == "push") {
1472
return;
1473
}
1474
let pollDuration = Date.now() - this.pollStartDate;
1475
// Polling session expired.
1476
if (pollDuration >= this.POLL_SESSION) {
1477
if (currentState.whenVerifiedDeferred) {
1478
let error = new Error("User email verification timed out.");
1479
this._rejectWhenVerified(currentState, error);
1480
}
1481
log.debug("polling session exceeded, giving up");
1482
return;
1483
}
1484
// Poll email status again after a short delay.
1485
if (nextPollMs === undefined) {
1486
let currentMinute = Math.ceil(pollDuration / 60000);
1487
nextPollMs =
1488
why == "start" &&
1489
currentMinute < this.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD
1490
? this.VERIFICATION_POLL_TIMEOUT_INITIAL
1491
: this.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT;
1492
}
1493
this._scheduleNextPollEmailStatus(
1494
currentState,
1495
sessionToken,
1496
nextPollMs,
1497
why
1498
);
1499
},
1500
1501
// Easy-to-mock testable method
1502
_scheduleNextPollEmailStatus(currentState, sessionToken, nextPollMs, why) {
1503
log.debug("polling with timeout = " + nextPollMs);
1504
this.currentTimer = setTimeout(() => {
1505
this.pollEmailStatus(currentState, sessionToken, why);
1506
}, nextPollMs);
1507
},
1508
1509
async onPollEmailSuccess(currentState) {
1510
try {
1511
await currentState.updateUserAccountData({ verified: true });
1512
const accountData = await currentState.getUserAccountData();
1513
// Now that the user is verified, we can proceed to fetch keys
1514
if (currentState.whenVerifiedDeferred) {
1515
currentState.whenVerifiedDeferred.resolve(accountData);
1516
delete currentState.whenVerifiedDeferred;
1517
}
1518
} catch (e) {
1519
log.error(e);
1520
}
1521
},
1522
1523
_rejectWhenVerified(currentState, error) {
1524
currentState.whenVerifiedDeferred.reject(error);
1525
delete currentState.whenVerifiedDeferred;
1526
},
1527
1528
// Does the actual fetch of an oauth token for getOAuthToken()
1529
async _doTokenFetch(scopeString) {
1530
// Ideally, we would auth this call directly with our `sessionToken` rather than
1531
// going via a BrowserID assertion. Before we can do so we need to resolve some
1532
// data-volume processing issues in the server-side FxA metrics pipeline.
1533
let token;
1534
let oAuthURL = this.fxAccountsOAuthGrantClient.serverURL.href;
1535
let assertion = await this.getAssertion(oAuthURL);
1536
try {
1537
let result = await this.fxAccountsOAuthGrantClient.getTokenFromAssertion(
1538
assertion,
1539
scopeString
1540
);
1541
token = result.access_token;
1542
} catch (err) {
1543
// If we get a 401 fetching the token it may be that our certificate
1544
// needs to be regenerated.
1545
if (err.code !== 401 || err.errno !== ERRNO_INVALID_FXA_ASSERTION) {
1546
throw err;
1547
}
1548
log.warn(
1549
"OAuth server returned 401, refreshing certificate and retrying token fetch"
1550
);
1551
await this.invalidateCertificate();
1552
assertion = await this.getAssertion(oAuthURL);
1553
let result = await this.fxAccountsOAuthGrantClient.getTokenFromAssertion(
1554
assertion,
1555
scopeString
1556
);
1557
token = result.access_token;
1558
}
1559
return token;
1560
},
1561
1562
getOAuthToken(options = {}) {
1563
log.debug("getOAuthToken enter");
1564
let scope = options.scope;
1565
if (typeof scope === "string") {
1566
scope = [scope];
1567
}
1568
1569
if (!scope || !scope.length) {
1570
return Promise.reject(
1571
this._error(
1572
ERROR_INVALID_PARAMETER,
1573
"Missing or invalid 'scope' option"
1574
)
1575
);
1576
}
1577
1578
return this.withVerifiedAccountState(async currentState => {
1579
// Early exit for a cached token.
1580
let cached = currentState.getCachedToken(scope);
1581
if (cached) {
1582
log.debug("getOAuthToken returning a cached token");
1583
return cached.token;
1584
}
1585
1586
// Build the string we use in our "inflight" map and that we send to the
1587
// server. Because it's used as a key in the map we sort the scopes.
1588
let scopeString = scope.sort().join(" ");
1589
1590
// We keep a map of in-flight requests to avoid multiple promise-based
1591
// consumers concurrently requesting the same token.
1592
let maybeInFlight = currentState.inFlightTokenRequests.get(scopeString);
1593
if (maybeInFlight) {
1594
log.debug("getOAuthToken has an in-flight request for this scope");
1595
return maybeInFlight;
1596
}
1597
1598
// We need to start a new fetch and stick the promise in our in-flight map
1599
// and remove it when it resolves.
1600
let promise = this._doTokenFetch(scopeString)
1601
.then(token => {
1602
// As a sanity check, ensure something else hasn't raced getting a token
1603
// of the same scope. If something has we just make noise rather than
1604
// taking any concrete action because it should never actually happen.
1605
if (currentState.getCachedToken(scope)) {
1606
log.error(`detected a race for oauth token with scope ${scope}`);
1607
}
1608
// If we got one, cache it.
1609
if (token) {
1610
let entry = { token };
1611
currentState.setCachedToken(scope, entry);
1612
}
1613
return token;
1614
})
1615
.finally(() => {
1616
// Remove ourself from the in-flight map. There's no need to check the
1617
// result of .delete() to handle a signout race, because setCachedToken
1618
// above will fail in that case and cause the entire call to fail.
1619
currentState.inFlightTokenRequests.delete(scopeString);
1620
});
1621
1622
currentState.inFlightTokenRequests.set(scopeString, promise);
1623
return promise;
1624
});
1625
},
1626
1627
removeCachedOAuthToken(options) {
1628
if (!options.token || typeof options.token !== "string") {
1629
throw this._error(
1630
ERROR_INVALID_PARAMETER,
1631
"Missing or invalid 'token' option"
1632
);
1633
}
1634
return this.withCurrentAccountState(currentState => {
1635
let existing = currentState.removeCachedToken(options.token);
1636
if (existing) {
1637
// background destroy.
1638
this._destroyOAuthToken(existing).catch(err => {
1639
log.warn("FxA failed to revoke a cached token", err);
1640
});
1641
}
1642
});
1643
},
1644
1645
/**
1646
* Invalidate the FxA certificate, so that it will be refreshed from the server
1647
* the next time it is needed.
1648
*/
1649
invalidateCertificate() {
1650
return this.withCurrentAccountState(async currentState => {
1651
await currentState.updateUserAccountData({ cert: null });
1652
});
1653
},
1654
1655
/**
1656
*
1657
* @param {String} clientId
1658
* @param {String} scope Space separated requested scopes
1659
* @param {Object} jwk
1660
*/
1661
async createKeysJWE(clientId, scope, jwk) {
1662
let scopedKeys = await this.keys.getScopedKeys(scope, clientId);
1663
scopedKeys = new TextEncoder().encode(JSON.stringify(scopedKeys));
1664
return jwcrypto.generateJWE(jwk, scopedKeys);
1665
},
1666
1667
async _getVerifiedAccountOrReject() {
1668
let data = await this.currentAccountState.getUserAccountData();
1669
if (!data) {
1670
// No signed-in user
1671
throw this._error(ERROR_NO_ACCOUNT);
1672
}
1673
if (!this.isUserEmailVerified(data)) {
1674
// Signed-in user has not verified email
1675
throw this._error(ERROR_UNVERIFIED_ACCOUNT);
1676
}
1677
return data;
1678
},
1679
1680
// _handle* methods used by push, used when the account/device status is
1681
// changed on a different device.
1682
async _handleAccountDestroyed(uid) {
1683
let state = this.currentAccountState;
1684
const accountData = await state.getUserAccountData();
1685
const localUid = accountData ? accountData.uid : null;
1686
if (!localUid) {
1687
log.info(
1688
`Account destroyed push notification received, but we're already logged-out`
1689
);
1690
return null;
1691
}
1692
if (uid == localUid) {
1693
const data = JSON.stringify({ isLocalDevice: true });
1694
await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
1695
return this.signOut(true);
1696
}
1697
log.info(
1698
`The destroyed account uid doesn't match with the local uid. ` +
1699
`Local: ${localUid}, account uid destroyed: ${uid}`
1700
);
1701
return null;
1702
},
1703
1704
async _handleDeviceDisconnection(deviceId) {
1705
let state = this.currentAccountState;
1706
const accountData = await state.getUserAccountData();
1707
if (!accountData || !accountData.device) {
1708
// Nothing we can do here.
1709
return;
1710
}
1711
const localDeviceId = accountData.device.id;
1712
const isLocalDevice = deviceId == localDeviceId;
1713
if (isLocalDevice) {
1714
this.signOut(true);
1715
}
1716
const data = JSON.stringify({ isLocalDevice });
1717
await this.notifyObservers(ON_DEVICE_DISCONNECTED_NOTIFICATION, data);
1718
},
1719
1720
async _handleEmailUpdated(newEmail) {
1721
Services.prefs.setStringPref(
1722
PREF_LAST_FXA_USER,
1723
CryptoUtils.sha256Base64(newEmail)
1724
);
1725
await this.currentAccountState.updateUserAccountData({ email: newEmail });
1726
},
1727
1728
/*
1729
* Coerce an error into one of the general error cases:
1730
* NETWORK_ERROR
1731
* AUTH_ERROR
1732
* UNKNOWN_ERROR
1733
*
1734
* These errors will pass through:
1735
* INVALID_PARAMETER
1736
* NO_ACCOUNT
1737
* UNVERIFIED_ACCOUNT
1738
*/
1739
_errorToErrorClass(aError) {
1740
if (aError.errno) {
1741
let error = SERVER_ERRNO_TO_ERROR[aError.errno];
1742
return this._error(
1743
ERROR_TO_GENERAL_ERROR_CLASS[error] || ERROR_UNKNOWN,
1744
aError
1745
);
1746
} else if (
1747
aError.message &&
1748
(aError.message === "INVALID_PARAMETER" ||
1749
aError.message === "NO_ACCOUNT" ||
1750
aError.message === "UNVERIFIED_ACCOUNT" ||
1751
aError.message === "AUTH_ERROR")
1752
) {
1753
return aError;
1754
}
1755
return this._error(ERROR_UNKNOWN, aError);
1756
},
1757
1758
_error(aError, aDetails) {
1759
log.error("FxA rejecting with error ${aError}, details: ${aDetails}", {
1760
aError,
1761
aDetails,
1762
});
1763
let reason = new Error(aError);
1764
if (aDetails) {
1765
reason.details = aDetails;
1766
}
1767
return reason;
1768
},
1769
1770
// Attempt to update the auth server with whatever device details are stored
1771
// in the account data. Returns a promise that always resolves, never rejects.
1772
// If the promise resolves to a value, that value is the device id.
1773
updateDeviceRegistration() {
1774
return this.device.updateDeviceRegistration();
1775
},
1776
1777
/**
1778
* Delete all the persisted credentials we store for FxA. After calling
1779
* this, the user will be forced to re-authenticate to continue.
1780
*
1781
* @return Promise resolves when the user data has been persisted
1782
*/
1783
dropCredentials(state) {
1784
// Delete all fields except those required for the user to
1785
// reauthenticate.
1786
let updateData = {};
1787
let clearField = field => {
1788
if (!FXA_PWDMGR_REAUTH_WHITELIST.has(field)) {
1789
updateData[field] = null;
1790
}
1791
};
1792
FXA_PWDMGR_PLAINTEXT_FIELDS.forEach(clearField);
1793
FXA_PWDMGR_SECURE_FIELDS.forEach(clearField);
1794
FXA_PWDMGR_MEMORY_FIELDS.forEach(clearField);
1795
1796
return state.updateUserAccountData(updateData);
1797
},
1798
1799
async checkAccountStatus(state) {
1800
log.info("checking account status...");
1801
let data = await state.getUserAccountData(["uid", "sessionToken"]);
1802
if (!data) {
1803
log.info("account status: no user");
1804
return false;
1805
}
1806
// If we have a session token, then check if that remains valid - if this
1807
// works we know the account must also be OK.
1808
if (data.sessionToken) {
1809
if (await this.fxAccountsClient.sessionStatus(data.sessionToken)) {
1810
log.info("account status: ok");
1811
return true;
1812
}
1813
}
1814
let exists = await this.fxAccountsClient.accountStatus(data.uid);
1815
if (!exists) {
1816
// Delete all local account data. Since the account no longer
1817
// exists, we can skip the remote calls.
1818
log.info("account status: deleted");
1819
await this._handleAccountDestroyed(data.uid);
1820
} else {
1821
// Note that we may already have been in a "needs reauth" state (ie, if
1822
// this function was called when we already had no session token), but
1823
// that's OK - re-notifying etc should cause no harm.
1824
log.info("account status: needs reauthentication");
1825
await this.dropCredentials(this.currentAccountState);
1826
// Notify the account state has changed so the UI updates.
1827
await this.notifyObservers(ON_ACCOUNT_STATE_CHANGE_NOTIFICATION);
1828
}
1829
return false;
1830
},
1831
1832
async _handleTokenError(err) {
1833
if (!err || err.code != 401 || err.errno != ERRNO_INVALID_AUTH_TOKEN) {
1834
throw err;
1835
}
1836
log.warn("handling invalid token error", err);
1837
// Note that we don't use `withCurrentAccountState` here as that will cause
1838
// an error to be thrown if we sign out due to the account not existing.
1839
let state = this.currentAccountState;
1840
let ok = await this.checkAccountStatus(state);
1841
if (ok) {
1842
log.warn("invalid token error, but account state appears ok?");
1843
}
1844
// always re-throw the error.
1845
throw err;
1846
},
1847
};
1848
1849
// A getter for the instance to export
1850
XPCOMUtils.defineLazyGetter(this, "fxAccounts", function() {
1851
let a = new FxAccounts();
1852
1853
// XXX Bug 947061 - We need a strategy for resuming email verification after
1854
// browser restart
1855
a._internal.loadAndPoll();
1856
1857
return a;
1858
});
1859
1860
var EXPORTED_SYMBOLS = ["fxAccounts", "FxAccounts"];