test_oauth_flow.js (13217B)
1 /* Any copyright is dedicated to the Public Domain. 2 * http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 /* global crypto */ 5 6 "use strict"; 7 8 const { 9 FxAccountsOAuth, 10 ERROR_INVALID_SCOPES, 11 ERROR_INVALID_SCOPED_KEYS, 12 ERROR_INVALID_STATE, 13 ERROR_OAUTH_FLOW_ABANDONED, 14 } = ChromeUtils.importESModule( 15 "resource://gre/modules/FxAccountsOAuth.sys.mjs" 16 ); 17 18 const { SCOPE_PROFILE, OAUTH_CLIENT_ID } = ChromeUtils.importESModule( 19 "resource://gre/modules/FxAccountsCommon.sys.mjs" 20 ); 21 22 ChromeUtils.defineESModuleGetters(this, { 23 jwcrypto: "moz-src:///services/crypto/modules/jwcrypto.sys.mjs", 24 FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs", 25 }); 26 27 initTestLogging("Trace"); 28 29 add_task(function test_begin_oauth_flow() { 30 const oauth = new FxAccountsOAuth(); 31 add_task(async function test_begin_oauth_flow_invalid_scopes() { 32 try { 33 await oauth.beginOAuthFlow("foo,fi,fum", "foo"); 34 Assert.fail("Should have thrown error, scopes must be an array"); 35 } catch (e) { 36 Assert.equal(e.message, ERROR_INVALID_SCOPES); 37 } 38 try { 39 await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]); 40 Assert.fail("Should have thrown an error, must use a valid scope"); 41 } catch (e) { 42 Assert.equal(e.message, ERROR_INVALID_SCOPES); 43 } 44 }); 45 add_task(async function test_begin_oauth_flow_ok() { 46 const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC]; 47 const queryParams = await oauth.beginOAuthFlow(scopes); 48 49 // First verify default query parameters 50 Assert.equal(queryParams.client_id, OAUTH_CLIENT_ID); 51 Assert.equal(queryParams.action, "email"); 52 Assert.equal(queryParams.response_type, "code"); 53 Assert.equal(queryParams.access_type, "offline"); 54 Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_APP_SYNC].join(" ")); 55 56 // Then, we verify that the state is a valid Base64 value 57 const state = queryParams.state; 58 ChromeUtils.base64URLDecode(state, { padding: "reject" }); 59 60 // Then, we verify that the codeVerifier, can be used to verify the code_challenge 61 const code_challenge = queryParams.code_challenge; 62 Assert.equal(queryParams.code_challenge_method, "S256"); 63 const oauthFlow = oauth.getFlow(state); 64 const codeVerifierB64 = oauthFlow.verifier; 65 const expectedChallenge = await crypto.subtle.digest( 66 "SHA-256", 67 new TextEncoder().encode(codeVerifierB64) 68 ); 69 const expectedChallengeB64 = ChromeUtils.base64URLEncode( 70 expectedChallenge, 71 { pad: false } 72 ); 73 Assert.equal(expectedChallengeB64, code_challenge); 74 75 // Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key 76 const keysJwk = queryParams.keys_jwk; 77 const decodedKeysJwk = JSON.parse( 78 new TextDecoder().decode( 79 ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" }) 80 ) 81 ); 82 const plaintext = "text to be encrypted and decrypted!"; 83 delete decodedKeysJwk.key_ops; 84 const jwe = await jwcrypto.generateJWE( 85 decodedKeysJwk, 86 new TextEncoder().encode(plaintext) 87 ); 88 const privateKey = oauthFlow.key; 89 const decrypted = await jwcrypto.decryptJWE(jwe, privateKey); 90 Assert.equal(new TextDecoder().decode(decrypted), plaintext); 91 92 // Finally, we verify that we stored the requested scopes 93 Assert.deepEqual(oauthFlow.requestedScopes, scopes.join(" ")); 94 }); 95 }); 96 97 add_task(function test_complete_oauth_flow() { 98 add_task(async function test_invalid_state() { 99 const oauth = new FxAccountsOAuth(); 100 const code = "foo"; 101 const state = "bar"; 102 const sessionToken = "01abcef12"; 103 try { 104 await oauth.completeOAuthFlow(sessionToken, code, state); 105 Assert.fail("Should have thrown an error"); 106 } catch (err) { 107 Assert.equal(err.message, ERROR_INVALID_STATE); 108 } 109 }); 110 add_task(async function test_sync_scope_not_authorized() { 111 const fxaClient = { 112 oauthToken: () => 113 Promise.resolve({ 114 access_token: "access_token", 115 refresh_token: "refresh_token", 116 // Note that the scope does not include the sync scope 117 scope: SCOPE_PROFILE, 118 }), 119 }; 120 const oauth = new FxAccountsOAuth(fxaClient); 121 const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC]; 122 const sessionToken = "01abcef12"; 123 const queryParams = await oauth.beginOAuthFlow(scopes); 124 try { 125 await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state); 126 } catch (err) { 127 Assert.fail( 128 "We should throw if we don't receive requested scope, we skip sync setup" 129 ); 130 } 131 }); 132 add_task(async function test_jwe_not_returned() { 133 const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC]; 134 const fxaClient = { 135 oauthToken: () => 136 Promise.resolve({ 137 access_token: "access_token", 138 refresh_token: "refresh_token", 139 scope: scopes.join(" "), 140 }), 141 }; 142 const oauth = new FxAccountsOAuth(fxaClient); 143 const queryParams = await oauth.beginOAuthFlow(scopes); 144 const sessionToken = "01abcef12"; 145 try { 146 await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state); 147 } catch (err) { 148 Assert.fail( 149 "Should not have thrown an error because we didn't get back a keys_jwe,we instead skip sync setup" 150 ); 151 } 152 }); 153 add_task(async function test_complete_oauth_ok() { 154 // First, we initialize some fake values we would typically get 155 // from outside our system 156 const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC]; 157 const oauthCode = "fake oauth code"; 158 const sessionToken = "01abcef12"; 159 const plainTextScopedKeys = { 160 [SCOPE_APP_SYNC]: { 161 kty: "oct", 162 kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw", 163 k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", 164 scope: SCOPE_APP_SYNC, 165 }, 166 }; 167 const fakeAccessToken = "fake access token"; 168 const fakeRefreshToken = "fake refresh token"; 169 // Then, we initialize a fake http client, we'll add our fake oauthToken call 170 // once we have started the oauth flow (so we have the public keys!) 171 const fxaClient = {}; 172 const fxaKeys = new FxAccountsKeys(null); 173 // Then, we initialize our oauth object with the given client and begin a new flow 174 const oauth = new FxAccountsOAuth(fxaClient, fxaKeys); 175 const queryParams = await oauth.beginOAuthFlow(scopes); 176 // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE 177 // representing our scoped keys 178 const keysJwk = queryParams.keys_jwk; 179 const decodedKeysJwk = JSON.parse( 180 new TextDecoder().decode( 181 ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" }) 182 ) 183 ); 184 delete decodedKeysJwk.key_ops; 185 const jwe = await jwcrypto.generateJWE( 186 decodedKeysJwk, 187 new TextEncoder().encode(JSON.stringify(plainTextScopedKeys)) 188 ); 189 // We also grab the stored PKCE verifier that the oauth object stored internally 190 // to verify that we correctly send it as a part of our HTTP request 191 const storedVerifier = oauth.getFlow(queryParams.state).verifier; 192 193 // To test what happens when more than one flow is completed simulatniously 194 // We mimic a slow network call on the first oauthToken call and let the second 195 // one win 196 let callCount = 0; 197 let slowResolve; 198 const resolveFn = (payload, resolve) => { 199 if (callCount === 1) { 200 // This is the second call 201 // lets resolve it so the second call wins 202 resolve(payload); 203 } else { 204 callCount += 1; 205 // This is the first call, let store our resolve function for later 206 // it will be resolved once the fast flow is fully completed 207 slowResolve = () => resolve(payload); 208 } 209 }; 210 211 // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct 212 // parameters and returns what we'd expect a healthy HTTP Response would look like 213 fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => { 214 Assert.equal(sessionTokenHex, sessionToken); 215 Assert.equal(code, oauthCode); 216 Assert.equal(verifier, storedVerifier); 217 Assert.equal(clientId, queryParams.client_id); 218 const response = { 219 access_token: fakeAccessToken, 220 refresh_token: fakeRefreshToken, 221 scope: scopes.join(" "), 222 keys_jwe: jwe, 223 }; 224 return new Promise(resolve => { 225 resolveFn(response, resolve); 226 }); 227 }; 228 229 // Then, we call the completeOAuthFlow function, and get back our access token, 230 // refresh token and scopedKeys 231 232 // To test what happens when multiple flows race, we create two flows, 233 // A slow one that will start first, but finish last 234 // And a fast one that will beat the slow one 235 const firstCompleteOAuthFlow = oauth 236 .completeOAuthFlow(sessionToken, oauthCode, queryParams.state) 237 .then(res => { 238 // To mimic the slow network connection on the slowCompleteOAuthFlow 239 // We resume the slow completeOAuthFlow once this one is complete 240 slowResolve(); 241 return res; 242 }); 243 const secondCompleteOAuthFlow = oauth 244 .completeOAuthFlow(sessionToken, oauthCode, queryParams.state) 245 .then(res => { 246 // since we can't fully gaurentee which oauth flow finishes first, we also resolve here 247 slowResolve(); 248 return res; 249 }); 250 251 const { accessToken, refreshToken, scopedKeys } = await Promise.allSettled([ 252 firstCompleteOAuthFlow, 253 secondCompleteOAuthFlow, 254 ]).then(results => { 255 let fast; 256 let slow; 257 for (const result of results) { 258 if (result.status === "fulfilled") { 259 fast = result.value; 260 } else { 261 slow = result.reason; 262 } 263 } 264 // We make sure that we indeed have one slow flow that lost 265 Assert.equal(slow.message, ERROR_OAUTH_FLOW_ABANDONED); 266 return fast; 267 }); 268 269 Assert.equal(accessToken, fakeAccessToken); 270 Assert.equal(refreshToken, fakeRefreshToken); 271 Assert.deepEqual(scopedKeys, plainTextScopedKeys); 272 273 // Finally, we verify that all stored flows were cleared 274 Assert.equal(oauth.numOfFlows(), 0); 275 }); 276 add_task(async function test_complete_oauth_invalid_scoped_keys() { 277 // First, we initialize some fake values we would typically get 278 // from outside our system 279 const scopes = [SCOPE_PROFILE, SCOPE_APP_SYNC]; 280 const oauthCode = "fake oauth code"; 281 const sessionToken = "01abcef12"; 282 const invalidScopedKeys = { 283 [SCOPE_APP_SYNC]: { 284 // ====== This is an invalid key type! Should be "oct", so we will raise an error once we realize 285 kty: "EC", 286 kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw", 287 k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang", 288 scope: SCOPE_APP_SYNC, 289 }, 290 }; 291 const fakeAccessToken = "fake access token"; 292 const fakeRefreshToken = "fake refresh token"; 293 // Then, we initialize a fake http client, we'll add our fake oauthToken call 294 // once we have started the oauth flow (so we have the public keys!) 295 const fxaClient = {}; 296 const fxaKeys = new FxAccountsKeys(null); 297 // Then, we initialize our oauth object with the given client and begin a new flow 298 const oauth = new FxAccountsOAuth(fxaClient, fxaKeys); 299 const queryParams = await oauth.beginOAuthFlow(scopes); 300 // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE 301 // representing our scoped keys 302 const keysJwk = queryParams.keys_jwk; 303 const decodedKeysJwk = JSON.parse( 304 new TextDecoder().decode( 305 ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" }) 306 ) 307 ); 308 delete decodedKeysJwk.key_ops; 309 const jwe = await jwcrypto.generateJWE( 310 decodedKeysJwk, 311 new TextEncoder().encode(JSON.stringify(invalidScopedKeys)) 312 ); 313 // We also grab the stored PKCE verifier that the oauth object stored internally 314 // to verify that we correctly send it as a part of our HTTP request 315 const storedVerifier = oauth.getFlow(queryParams.state).verifier; 316 317 // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct 318 // parameters and returns what we'd expect a healthy HTTP Response would look like 319 fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => { 320 Assert.equal(sessionTokenHex, sessionToken); 321 Assert.equal(code, oauthCode); 322 Assert.equal(verifier, storedVerifier); 323 Assert.equal(clientId, queryParams.client_id); 324 const response = { 325 access_token: fakeAccessToken, 326 refresh_token: fakeRefreshToken, 327 scope: scopes.join(" "), 328 keys_jwe: jwe, 329 }; 330 return Promise.resolve(response); 331 }; 332 333 // Then, we call the completeOAuthFlow function, and get back our access token, 334 // refresh token and scopedKeys 335 try { 336 await oauth.completeOAuthFlow(sessionToken, oauthCode, queryParams.state); 337 Assert.fail( 338 "Should have thrown an error because the scoped keys are not valid" 339 ); 340 } catch (err) { 341 Assert.equal(err.message, ERROR_INVALID_SCOPED_KEYS); 342 } 343 }); 344 });