|
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 |
|
5 this.EXPORTED_SYMBOLS = ["FxAccountsClient"]; |
|
6 |
|
7 const {classes: Cc, interfaces: Ci, utils: Cu} = Components; |
|
8 |
|
9 Cu.import("resource://gre/modules/Log.jsm"); |
|
10 Cu.import("resource://gre/modules/Promise.jsm"); |
|
11 Cu.import("resource://gre/modules/Services.jsm"); |
|
12 Cu.import("resource://services-common/utils.js"); |
|
13 Cu.import("resource://services-common/hawkclient.js"); |
|
14 Cu.import("resource://services-crypto/utils.js"); |
|
15 Cu.import("resource://gre/modules/FxAccountsCommon.js"); |
|
16 Cu.import("resource://gre/modules/Credentials.jsm"); |
|
17 |
|
18 const HOST = Services.prefs.getCharPref("identity.fxaccounts.auth.uri"); |
|
19 |
|
20 this.FxAccountsClient = function(host = HOST) { |
|
21 this.host = host; |
|
22 |
|
23 // The FxA auth server expects requests to certain endpoints to be authorized |
|
24 // using Hawk. |
|
25 this.hawk = new HawkClient(host); |
|
26 this.hawk.observerPrefix = "FxA:hawk"; |
|
27 |
|
28 // Manage server backoff state. C.f. |
|
29 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#backoff-protocol |
|
30 this.backoffError = null; |
|
31 }; |
|
32 |
|
33 this.FxAccountsClient.prototype = { |
|
34 |
|
35 /** |
|
36 * Return client clock offset, in milliseconds, as determined by hawk client. |
|
37 * Provided because callers should not have to know about hawk |
|
38 * implementation. |
|
39 * |
|
40 * The offset is the number of milliseconds that must be added to the client |
|
41 * clock to make it equal to the server clock. For example, if the client is |
|
42 * five minutes ahead of the server, the localtimeOffsetMsec will be -300000. |
|
43 */ |
|
44 get localtimeOffsetMsec() { |
|
45 return this.hawk.localtimeOffsetMsec; |
|
46 }, |
|
47 |
|
48 /* |
|
49 * Return current time in milliseconds |
|
50 * |
|
51 * Not used by this module, but made available to the FxAccounts.jsm |
|
52 * that uses this client. |
|
53 */ |
|
54 now: function() { |
|
55 return this.hawk.now(); |
|
56 }, |
|
57 |
|
58 /** |
|
59 * Create a new Firefox Account and authenticate |
|
60 * |
|
61 * @param email |
|
62 * The email address for the account (utf8) |
|
63 * @param password |
|
64 * The user's password |
|
65 * @return Promise |
|
66 * Returns a promise that resolves to an object: |
|
67 * { |
|
68 * uid: the user's unique ID (hex) |
|
69 * sessionToken: a session token (hex) |
|
70 * keyFetchToken: a key fetch token (hex) |
|
71 * } |
|
72 */ |
|
73 signUp: function(email, password) { |
|
74 return Credentials.setup(email, password).then((creds) => { |
|
75 let data = { |
|
76 email: creds.emailUTF8, |
|
77 authPW: CommonUtils.bytesAsHex(creds.authPW), |
|
78 }; |
|
79 return this._request("/account/create", "POST", null, data); |
|
80 }); |
|
81 }, |
|
82 |
|
83 /** |
|
84 * Authenticate and create a new session with the Firefox Account API server |
|
85 * |
|
86 * @param email |
|
87 * The email address for the account (utf8) |
|
88 * @param password |
|
89 * The user's password |
|
90 * @param [getKeys=false] |
|
91 * If set to true the keyFetchToken will be retrieved |
|
92 * @param [retryOK=true] |
|
93 * If capitalization of the email is wrong and retryOK is set to true, |
|
94 * we will retry with the suggested capitalization from the server |
|
95 * @return Promise |
|
96 * Returns a promise that resolves to an object: |
|
97 * { |
|
98 * authAt: authentication time for the session (seconds since epoch) |
|
99 * email: the primary email for this account |
|
100 * keyFetchToken: a key fetch token (hex) |
|
101 * sessionToken: a session token (hex) |
|
102 * uid: the user's unique ID (hex) |
|
103 * unwrapBKey: used to unwrap kB, derived locally from the |
|
104 * password (not revealed to the FxA server) |
|
105 * verified: flag indicating verification status of the email |
|
106 * } |
|
107 */ |
|
108 signIn: function signIn(email, password, getKeys=false, retryOK=true) { |
|
109 return Credentials.setup(email, password).then((creds) => { |
|
110 let data = { |
|
111 authPW: CommonUtils.bytesAsHex(creds.authPW), |
|
112 email: creds.emailUTF8, |
|
113 }; |
|
114 let keys = getKeys ? "?keys=true" : ""; |
|
115 |
|
116 return this._request("/account/login" + keys, "POST", null, data).then( |
|
117 // Include the canonical capitalization of the email in the response so |
|
118 // the caller can set its signed-in user state accordingly. |
|
119 result => { |
|
120 result.email = data.email; |
|
121 result.unwrapBKey = CommonUtils.bytesAsHex(creds.unwrapBKey); |
|
122 |
|
123 return result; |
|
124 }, |
|
125 error => { |
|
126 log.debug("signIn error: " + JSON.stringify(error)); |
|
127 // If the user entered an email with different capitalization from |
|
128 // what's stored in the database (e.g., Greta.Garbo@gmail.COM as |
|
129 // opposed to greta.garbo@gmail.com), the server will respond with a |
|
130 // errno 120 (code 400) and the expected capitalization of the email. |
|
131 // We retry with this email exactly once. If successful, we use the |
|
132 // server's version of the email as the signed-in-user's email. This |
|
133 // is necessary because the email also serves as salt; so we must be |
|
134 // in agreement with the server on capitalization. |
|
135 // |
|
136 // API reference: |
|
137 // https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md |
|
138 if (ERRNO_INCORRECT_EMAIL_CASE === error.errno && retryOK) { |
|
139 if (!error.email) { |
|
140 log.error("Server returned errno 120 but did not provide email"); |
|
141 throw error; |
|
142 } |
|
143 return this.signIn(error.email, password, getKeys, false); |
|
144 } |
|
145 throw error; |
|
146 } |
|
147 ); |
|
148 }); |
|
149 }, |
|
150 |
|
151 /** |
|
152 * Destroy the current session with the Firefox Account API server |
|
153 * |
|
154 * @param sessionTokenHex |
|
155 * The session token encoded in hex |
|
156 * @return Promise |
|
157 */ |
|
158 signOut: function (sessionTokenHex) { |
|
159 return this._request("/session/destroy", "POST", |
|
160 this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); |
|
161 }, |
|
162 |
|
163 /** |
|
164 * Check the verification status of the user's FxA email address |
|
165 * |
|
166 * @param sessionTokenHex |
|
167 * The current session token encoded in hex |
|
168 * @return Promise |
|
169 */ |
|
170 recoveryEmailStatus: function (sessionTokenHex) { |
|
171 return this._request("/recovery_email/status", "GET", |
|
172 this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); |
|
173 }, |
|
174 |
|
175 /** |
|
176 * Resend the verification email for the user |
|
177 * |
|
178 * @param sessionTokenHex |
|
179 * The current token encoded in hex |
|
180 * @return Promise |
|
181 */ |
|
182 resendVerificationEmail: function(sessionTokenHex) { |
|
183 return this._request("/recovery_email/resend_code", "POST", |
|
184 this._deriveHawkCredentials(sessionTokenHex, "sessionToken")); |
|
185 }, |
|
186 |
|
187 /** |
|
188 * Retrieve encryption keys |
|
189 * |
|
190 * @param keyFetchTokenHex |
|
191 * A one-time use key fetch token encoded in hex |
|
192 * @return Promise |
|
193 * Returns a promise that resolves to an object: |
|
194 * { |
|
195 * kA: an encryption key for recevorable data (bytes) |
|
196 * wrapKB: an encryption key that requires knowledge of the |
|
197 * user's password (bytes) |
|
198 * } |
|
199 */ |
|
200 accountKeys: function (keyFetchTokenHex) { |
|
201 let creds = this._deriveHawkCredentials(keyFetchTokenHex, "keyFetchToken"); |
|
202 let keyRequestKey = creds.extra.slice(0, 32); |
|
203 let morecreds = CryptoUtils.hkdf(keyRequestKey, undefined, |
|
204 Credentials.keyWord("account/keys"), 3 * 32); |
|
205 let respHMACKey = morecreds.slice(0, 32); |
|
206 let respXORKey = morecreds.slice(32, 96); |
|
207 |
|
208 return this._request("/account/keys", "GET", creds).then(resp => { |
|
209 if (!resp.bundle) { |
|
210 throw new Error("failed to retrieve keys"); |
|
211 } |
|
212 |
|
213 let bundle = CommonUtils.hexToBytes(resp.bundle); |
|
214 let mac = bundle.slice(-32); |
|
215 |
|
216 let hasher = CryptoUtils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, |
|
217 CryptoUtils.makeHMACKey(respHMACKey)); |
|
218 |
|
219 let bundleMAC = CryptoUtils.digestBytes(bundle.slice(0, -32), hasher); |
|
220 if (mac !== bundleMAC) { |
|
221 throw new Error("error unbundling encryption keys"); |
|
222 } |
|
223 |
|
224 let keyAWrapB = CryptoUtils.xor(respXORKey, bundle.slice(0, 64)); |
|
225 |
|
226 return { |
|
227 kA: keyAWrapB.slice(0, 32), |
|
228 wrapKB: keyAWrapB.slice(32) |
|
229 }; |
|
230 }); |
|
231 }, |
|
232 |
|
233 /** |
|
234 * Sends a public key to the FxA API server and returns a signed certificate |
|
235 * |
|
236 * @param sessionTokenHex |
|
237 * The current session token encoded in hex |
|
238 * @param serializedPublicKey |
|
239 * A public key (usually generated by jwcrypto) |
|
240 * @param lifetime |
|
241 * The lifetime of the certificate |
|
242 * @return Promise |
|
243 * Returns a promise that resolves to the signed certificate. The certificate |
|
244 * can be used to generate a Persona assertion. |
|
245 */ |
|
246 signCertificate: function (sessionTokenHex, serializedPublicKey, lifetime) { |
|
247 let creds = this._deriveHawkCredentials(sessionTokenHex, "sessionToken"); |
|
248 |
|
249 let body = { publicKey: serializedPublicKey, |
|
250 duration: lifetime }; |
|
251 return Promise.resolve() |
|
252 .then(_ => this._request("/certificate/sign", "POST", creds, body)) |
|
253 .then(resp => resp.cert, |
|
254 err => { |
|
255 log.error("HAWK.signCertificate error: " + JSON.stringify(err)); |
|
256 throw err; |
|
257 }); |
|
258 }, |
|
259 |
|
260 /** |
|
261 * Determine if an account exists |
|
262 * |
|
263 * @param email |
|
264 * The email address to check |
|
265 * @return Promise |
|
266 * The promise resolves to true if the account exists, or false |
|
267 * if it doesn't. The promise is rejected on other errors. |
|
268 */ |
|
269 accountExists: function (email) { |
|
270 return this.signIn(email, "").then( |
|
271 (cantHappen) => { |
|
272 throw new Error("How did I sign in with an empty password?"); |
|
273 }, |
|
274 (expectedError) => { |
|
275 switch (expectedError.errno) { |
|
276 case ERRNO_ACCOUNT_DOES_NOT_EXIST: |
|
277 return false; |
|
278 break; |
|
279 case ERRNO_INCORRECT_PASSWORD: |
|
280 return true; |
|
281 break; |
|
282 default: |
|
283 // not so expected, any more ... |
|
284 throw expectedError; |
|
285 break; |
|
286 } |
|
287 } |
|
288 ); |
|
289 }, |
|
290 |
|
291 /** |
|
292 * The FxA auth server expects requests to certain endpoints to be authorized using Hawk. |
|
293 * Hawk credentials are derived using shared secrets, which depend on the context |
|
294 * (e.g. sessionToken vs. keyFetchToken). |
|
295 * |
|
296 * @param tokenHex |
|
297 * The current session token encoded in hex |
|
298 * @param context |
|
299 * A context for the credentials |
|
300 * @param size |
|
301 * The size in bytes of the expected derived buffer |
|
302 * @return credentials |
|
303 * Returns an object: |
|
304 * { |
|
305 * algorithm: sha256 |
|
306 * id: the Hawk id (from the first 32 bytes derived) |
|
307 * key: the Hawk key (from bytes 32 to 64) |
|
308 * extra: size - 64 extra bytes |
|
309 * } |
|
310 */ |
|
311 _deriveHawkCredentials: function (tokenHex, context, size) { |
|
312 let token = CommonUtils.hexToBytes(tokenHex); |
|
313 let out = CryptoUtils.hkdf(token, undefined, Credentials.keyWord(context), size || 3 * 32); |
|
314 |
|
315 return { |
|
316 algorithm: "sha256", |
|
317 key: out.slice(32, 64), |
|
318 extra: out.slice(64), |
|
319 id: CommonUtils.bytesAsHex(out.slice(0, 32)) |
|
320 }; |
|
321 }, |
|
322 |
|
323 _clearBackoff: function() { |
|
324 this.backoffError = null; |
|
325 }, |
|
326 |
|
327 /** |
|
328 * A general method for sending raw API calls to the FxA auth server. |
|
329 * All request bodies and responses are JSON. |
|
330 * |
|
331 * @param path |
|
332 * API endpoint path |
|
333 * @param method |
|
334 * The HTTP request method |
|
335 * @param credentials |
|
336 * Hawk credentials |
|
337 * @param jsonPayload |
|
338 * A JSON payload |
|
339 * @return Promise |
|
340 * Returns a promise that resolves to the JSON response of the API call, |
|
341 * or is rejected with an error. Error responses have the following properties: |
|
342 * { |
|
343 * "code": 400, // matches the HTTP status code |
|
344 * "errno": 107, // stable application-level error number |
|
345 * "error": "Bad Request", // string description of the error type |
|
346 * "message": "the value of salt is not allowed to be undefined", |
|
347 * "info": "https://docs.dev.lcip.og/errors/1234" // link to more info on the error |
|
348 * } |
|
349 */ |
|
350 _request: function hawkRequest(path, method, credentials, jsonPayload) { |
|
351 let deferred = Promise.defer(); |
|
352 |
|
353 // We were asked to back off. |
|
354 if (this.backoffError) { |
|
355 log.debug("Received new request during backoff, re-rejecting."); |
|
356 deferred.reject(this.backoffError); |
|
357 return deferred.promise; |
|
358 } |
|
359 |
|
360 this.hawk.request(path, method, credentials, jsonPayload).then( |
|
361 (responseText) => { |
|
362 try { |
|
363 let response = JSON.parse(responseText); |
|
364 deferred.resolve(response); |
|
365 } catch (err) { |
|
366 log.error("json parse error on response: " + responseText); |
|
367 deferred.reject({error: err}); |
|
368 } |
|
369 }, |
|
370 |
|
371 (error) => { |
|
372 log.error("error " + method + "ing " + path + ": " + JSON.stringify(error)); |
|
373 if (error.retryAfter) { |
|
374 log.debug("Received backoff response; caching error as flag."); |
|
375 this.backoffError = error; |
|
376 // Schedule clearing of cached-error-as-flag. |
|
377 CommonUtils.namedTimer( |
|
378 this._clearBackoff, |
|
379 error.retryAfter * 1000, |
|
380 this, |
|
381 "fxaBackoffTimer" |
|
382 ); |
|
383 } |
|
384 deferred.reject(error); |
|
385 } |
|
386 ); |
|
387 |
|
388 return deferred.promise; |
|
389 }, |
|
390 }; |
|
391 |