forked from mirrors/gecko-dev
Bug 1863372: Adds begin oauth flow implementation. r=markh,skhamis
Differential Revision: https://phabricator.services.mozilla.com/D192881
This commit is contained in:
parent
67539877a9
commit
4de760de62
5 changed files with 243 additions and 0 deletions
|
|
@ -45,6 +45,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
|
||||||
FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.sys.mjs",
|
FxAccountsConfig: "resource://gre/modules/FxAccountsConfig.sys.mjs",
|
||||||
FxAccountsDevice: "resource://gre/modules/FxAccountsDevice.sys.mjs",
|
FxAccountsDevice: "resource://gre/modules/FxAccountsDevice.sys.mjs",
|
||||||
FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs",
|
FxAccountsKeys: "resource://gre/modules/FxAccountsKeys.sys.mjs",
|
||||||
|
FxAccountsOAuth: "resource://gre/modules/FxAccountsOAuth.sys.mjs",
|
||||||
FxAccountsProfile: "resource://gre/modules/FxAccountsProfile.sys.mjs",
|
FxAccountsProfile: "resource://gre/modules/FxAccountsProfile.sys.mjs",
|
||||||
FxAccountsTelemetry: "resource://gre/modules/FxAccountsTelemetry.sys.mjs",
|
FxAccountsTelemetry: "resource://gre/modules/FxAccountsTelemetry.sys.mjs",
|
||||||
});
|
});
|
||||||
|
|
@ -829,6 +830,14 @@ FxAccountsInternal.prototype = {
|
||||||
return this._device;
|
return this._device;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_oauth: null,
|
||||||
|
get oauth() {
|
||||||
|
if (!this._oauth) {
|
||||||
|
this._oauth = new lazy.FxAccountsOAuth();
|
||||||
|
}
|
||||||
|
return this._oauth;
|
||||||
|
},
|
||||||
|
|
||||||
_telemetry: null,
|
_telemetry: null,
|
||||||
get telemetry() {
|
get telemetry() {
|
||||||
if (!this._telemetry) {
|
if (!this._telemetry) {
|
||||||
|
|
|
||||||
143
services/fxaccounts/FxAccountsOAuth.sys.mjs
Normal file
143
services/fxaccounts/FxAccountsOAuth.sys.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
import {
|
||||||
|
FX_OAUTH_CLIENT_ID,
|
||||||
|
SCOPE_PROFILE,
|
||||||
|
SCOPE_PROFILE_WRITE,
|
||||||
|
SCOPE_OLD_SYNC,
|
||||||
|
} from "resource://gre/modules/FxAccountsCommon.sys.mjs";
|
||||||
|
|
||||||
|
const VALID_SCOPES = [SCOPE_PROFILE, SCOPE_PROFILE_WRITE, SCOPE_OLD_SYNC];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles all logic and state related to initializing, and completing OAuth flows
|
||||||
|
* with FxA
|
||||||
|
* It's possible to start multiple OAuth flow, but only one can be completed, and once one flow is completed
|
||||||
|
* all the other in-flight flows will be concluded, and attempting to complete those flows will result in errors.
|
||||||
|
*/
|
||||||
|
export class FxAccountsOAuth {
|
||||||
|
#flow;
|
||||||
|
constructor() {
|
||||||
|
this.#flow = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a flow in-memory
|
||||||
|
* @param { string } state: A base-64 URL-safe string represnting a random value created at the start of the flow
|
||||||
|
* @param { Object } value: The data needed to complete a flow, once the oauth code is available.
|
||||||
|
* in practice, `value` is:
|
||||||
|
* - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
|
||||||
|
* - `key`: The private key need to decrypt the JWE we recieve from the auth server
|
||||||
|
* - `requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
|
||||||
|
*/
|
||||||
|
addFlow(state, value) {
|
||||||
|
this.#flow[state] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all started flows
|
||||||
|
*/
|
||||||
|
clearAllFlows() {
|
||||||
|
this.#flow = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Gets a stored flow
|
||||||
|
* @param { string } state: The base-64 URL-safe state string that was created at the start of the flow
|
||||||
|
* @returns { Object }: The values initially stored when startign th eoauth flow
|
||||||
|
* in practice, the return value is:
|
||||||
|
* - `verifier`: A base=64 URL-safe string representing the PKCE code verifier
|
||||||
|
* - `key`: The private key need to decrypt the JWE we recieve from the auth server
|
||||||
|
* - ``requestedScopes`: The scopes the caller requested, meant to be compared against the scopes the server authorized
|
||||||
|
*/
|
||||||
|
getFlow(state) {
|
||||||
|
return this.#flow[state];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Begins an OAuth flow, to be completed with a an OAuth code and state.
|
||||||
|
*
|
||||||
|
* This function stores needed information to complete the flow. You must call `completeOAuthFlow`
|
||||||
|
* on the same instance of `FxAccountsOAuth`, otherwise the completing of the oauth flow will fail.
|
||||||
|
*
|
||||||
|
* @param { string[] } scopes: The OAuth scopes the client should request from FxA
|
||||||
|
*
|
||||||
|
* @returns { Object }: Returns an object representing the query parameters that should be
|
||||||
|
* added to the FxA authorization URL to initialize an oAuth flow.
|
||||||
|
* In practice, the query parameters are:
|
||||||
|
* - `client_id`: The OAuth client ID for Firefox Desktop
|
||||||
|
* - `scope`: The scopes given by the caller, space seperated
|
||||||
|
* - `action`: This will always be `email`
|
||||||
|
* - `response_type`: This will always be `code`
|
||||||
|
* - `access_type`: This will always be `offline`
|
||||||
|
* - `state`: A URL-safe base-64 string randomly generated
|
||||||
|
* - `code_challenge`: A URL-safe base-64 string representing the PKCE challenge
|
||||||
|
* - `code_challenge_method`: This will always be `S256`
|
||||||
|
* For more informatio about PKCE, read https://datatracker.ietf.org/doc/html/rfc7636
|
||||||
|
* - `keys_jwk`: A URL-safe base-64 representing a JWK to be used as a public key by the server
|
||||||
|
* to generate a JWE
|
||||||
|
*/
|
||||||
|
async beginOAuthFlow(scopes) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(scopes) &&
|
||||||
|
scopes.some(scope => !VALID_SCOPES.contains(scope))
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid scopes");
|
||||||
|
}
|
||||||
|
const queryParams = {
|
||||||
|
client_id: FX_OAUTH_CLIENT_ID,
|
||||||
|
action: "email",
|
||||||
|
response_type: "code",
|
||||||
|
access_type: "offline",
|
||||||
|
scope: scopes.join(" "),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate a random, 16 byte value to represent a state that we verify
|
||||||
|
// once we complete the oauth flow, to ensure that we only conclude
|
||||||
|
// an oauth flow that we started
|
||||||
|
const state = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(state);
|
||||||
|
const stateB64 = ChromeUtils.base64URLEncode(state, { pad: false });
|
||||||
|
queryParams.state = stateB64;
|
||||||
|
|
||||||
|
// Generate a 43 byte code verifier for PKCE, in accordance with
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7636#section-7.1 which recommends a
|
||||||
|
// 43-octet URL safe string
|
||||||
|
const codeVerifier = new Uint8Array(43);
|
||||||
|
crypto.getRandomValues(codeVerifier);
|
||||||
|
const codeVerifierB64 = ChromeUtils.base64URLEncode(codeVerifier, {
|
||||||
|
pad: false,
|
||||||
|
});
|
||||||
|
const challenge = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(codeVerifierB64)
|
||||||
|
);
|
||||||
|
const challengeB64 = ChromeUtils.base64URLEncode(challenge, { pad: false });
|
||||||
|
queryParams.code_challenge = challengeB64;
|
||||||
|
queryParams.code_challenge_method = "S256";
|
||||||
|
|
||||||
|
// Generate a public, private key pair to be used during the oauth flow
|
||||||
|
// to encrypt scoped-keys as they roundtrip through the auth server
|
||||||
|
const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
|
||||||
|
const key = await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveKey"]);
|
||||||
|
const publicKey = await crypto.subtle.exportKey("jwk", key.publicKey);
|
||||||
|
const privateKey = key.privateKey;
|
||||||
|
|
||||||
|
// We encode the public key as URL-safe base64 to be included in the query parameters
|
||||||
|
const encodedPublicKey = ChromeUtils.base64URLEncode(
|
||||||
|
new TextEncoder().encode(JSON.stringify(publicKey)),
|
||||||
|
{ pad: false }
|
||||||
|
);
|
||||||
|
queryParams.keys_jwk = encodedPublicKey;
|
||||||
|
|
||||||
|
// We store the state in-memory, to verify once the oauth flow is completed
|
||||||
|
this.addFlow(stateB64, {
|
||||||
|
key: privateKey,
|
||||||
|
verifier: codeVerifierB64,
|
||||||
|
requestedScopes: scopes,
|
||||||
|
});
|
||||||
|
return queryParams;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ EXTRA_JS_MODULES += [
|
||||||
"FxAccountsConfig.sys.mjs",
|
"FxAccountsConfig.sys.mjs",
|
||||||
"FxAccountsDevice.sys.mjs",
|
"FxAccountsDevice.sys.mjs",
|
||||||
"FxAccountsKeys.sys.mjs",
|
"FxAccountsKeys.sys.mjs",
|
||||||
|
"FxAccountsOAuth.sys.mjs",
|
||||||
"FxAccountsPairing.sys.mjs",
|
"FxAccountsPairing.sys.mjs",
|
||||||
"FxAccountsPairingChannel.sys.mjs",
|
"FxAccountsPairingChannel.sys.mjs",
|
||||||
"FxAccountsProfile.sys.mjs",
|
"FxAccountsProfile.sys.mjs",
|
||||||
|
|
|
||||||
88
services/fxaccounts/tests/xpcshell/test_oauth_flow.js
Normal file
88
services/fxaccounts/tests/xpcshell/test_oauth_flow.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
/* Any copyright is dedicated to the Public Domain.
|
||||||
|
* http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||||
|
|
||||||
|
/* global crypto */
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const { FxAccountsOAuth } = ChromeUtils.importESModule(
|
||||||
|
"resource://gre/modules/FxAccountsOAuth.sys.mjs"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { SCOPE_PROFILE, FX_OAUTH_CLIENT_ID } = ChromeUtils.importESModule(
|
||||||
|
"resource://gre/modules/FxAccountsCommon.sys.mjs"
|
||||||
|
);
|
||||||
|
|
||||||
|
ChromeUtils.defineESModuleGetters(this, {
|
||||||
|
jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
|
||||||
|
});
|
||||||
|
|
||||||
|
initTestLogging("Trace");
|
||||||
|
|
||||||
|
add_task(function test_begin_oauth_flow() {
|
||||||
|
const oauth = new FxAccountsOAuth();
|
||||||
|
add_task(async function test_begin_oauth_flow_invalid_scopes() {
|
||||||
|
try {
|
||||||
|
await oauth.beginOAuthFlow("foo,fi,fum", "foo");
|
||||||
|
Assert.fail("Should have thrown error, scopes must be an array");
|
||||||
|
} catch {
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]);
|
||||||
|
Assert.fail("Should have thrown an error, must use a valid scope");
|
||||||
|
} catch {
|
||||||
|
// OK
|
||||||
|
}
|
||||||
|
});
|
||||||
|
add_task(async function test_begin_oauth_flow_ok() {
|
||||||
|
const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
|
||||||
|
const queryParams = await oauth.beginOAuthFlow(scopes);
|
||||||
|
|
||||||
|
// First verify default query parameters
|
||||||
|
Assert.equal(queryParams.client_id, FX_OAUTH_CLIENT_ID);
|
||||||
|
Assert.equal(queryParams.action, "email");
|
||||||
|
Assert.equal(queryParams.response_type, "code");
|
||||||
|
Assert.equal(queryParams.access_type, "offline");
|
||||||
|
Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_OLD_SYNC].join(" "));
|
||||||
|
|
||||||
|
// Then, we verify that the state is a valid Base64 value
|
||||||
|
const state = queryParams.state;
|
||||||
|
ChromeUtils.base64URLDecode(state, { padding: "reject" });
|
||||||
|
|
||||||
|
// Then, we verify that the codeVerifier, can be used to verify the code_challenge
|
||||||
|
const code_challenge = queryParams.code_challenge;
|
||||||
|
Assert.equal(queryParams.code_challenge_method, "S256");
|
||||||
|
const oauthFlow = oauth.getFlow(state);
|
||||||
|
const codeVerifierB64 = oauthFlow.verifier;
|
||||||
|
const expectedChallenge = await crypto.subtle.digest(
|
||||||
|
"SHA-256",
|
||||||
|
new TextEncoder().encode(codeVerifierB64)
|
||||||
|
);
|
||||||
|
const expectedChallengeB64 = ChromeUtils.base64URLEncode(
|
||||||
|
expectedChallenge,
|
||||||
|
{ pad: false }
|
||||||
|
);
|
||||||
|
Assert.equal(expectedChallengeB64, code_challenge);
|
||||||
|
|
||||||
|
// Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key
|
||||||
|
const keysJwk = queryParams.keys_jwk;
|
||||||
|
const decodedKeysJwk = JSON.parse(
|
||||||
|
new TextDecoder().decode(
|
||||||
|
ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const plaintext = "text to be encrypted and decrypted!";
|
||||||
|
delete decodedKeysJwk.key_ops;
|
||||||
|
const jwe = await jwcrypto.generateJWE(
|
||||||
|
decodedKeysJwk,
|
||||||
|
new TextEncoder().encode(plaintext)
|
||||||
|
);
|
||||||
|
const privateKey = oauthFlow.key;
|
||||||
|
const decrypted = await jwcrypto.decryptJWE(jwe, privateKey);
|
||||||
|
Assert.equal(new TextDecoder().decode(decrypted), plaintext);
|
||||||
|
|
||||||
|
// Finally, we verify that we stored the requested scopes
|
||||||
|
Assert.deepEqual(oauthFlow.requestedScopes, scopes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,6 +28,8 @@ support-files = [
|
||||||
|
|
||||||
["test_loginmgr_storage.js"]
|
["test_loginmgr_storage.js"]
|
||||||
|
|
||||||
|
["test_oauth_flow.js"]
|
||||||
|
|
||||||
["test_oauth_token_storage.js"]
|
["test_oauth_token_storage.js"]
|
||||||
|
|
||||||
["test_oauth_tokens.js"]
|
["test_oauth_tokens.js"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue