summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/FxAccountsOAuth.sys.mjs
blob: 1935decff21d0c78279c80f5a449d49c0068d999 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/* 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/. */

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
});

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];

export const ERROR_INVALID_SCOPES = "INVALID_SCOPES";
export const ERROR_INVALID_STATE = "INVALID_STATE";
export const ERROR_SYNC_SCOPE_NOT_GRANTED = "ERROR_SYNC_SCOPE_NOT_GRANTED";
export const ERROR_NO_KEYS_JWE = "ERROR_NO_KEYS_JWE";
export const ERROR_OAUTH_FLOW_ABANDONED = "ERROR_OAUTH_FLOW_ABANDONED";

/**
 * 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;
  #fxaClient;
  /**
   * Creates a new FxAccountsOAuth
   *
   * @param { Object } fxaClient: The fxa client used to send http request to the oauth server
   */
  constructor(fxaClient) {
    this.#flow = {};
    this.#fxaClient = fxaClient;
  }

  /**
   * 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];
  }

  /* Returns the number of flows, used by tests
   *
   */
  numOfFlows() {
    return Object.keys(this.#flow).length;
  }

  /**
   * 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.includes(scope))
    ) {
      throw new Error(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.join(" "),
    });
    return queryParams;
  }

  /** Completes an OAuth flow and invalidates any other ongoing flows
   * @param { string } sessionTokenHex: The session token encoded in hexadecimal
   * @param { string } code: OAuth authorization code provided by running an OAuth flow
   * @param { string } state: The state first provided by `beginOAuthFlow`, then roundtripped through the server
   *
   * @returns { Object }: Returns an object representing the result of completing the oauth flow.
   *   The object includes the following:
   *     - 'scopedKeys': The encryption keys provided by the server, already decrypted
   *     - 'refreshToken': The refresh token provided by the server
   *     - 'accessToken': The access token provided by the server
   * */
  async completeOAuthFlow(sessionTokenHex, code, state) {
    const flow = this.getFlow(state);
    if (!flow) {
      throw new Error(ERROR_INVALID_STATE);
    }
    const { key, verifier, requestedScopes } = flow;
    const { keys_jwe, refresh_token, access_token, scope } =
      await this.#fxaClient.oauthToken(
        sessionTokenHex,
        code,
        verifier,
        FX_OAUTH_CLIENT_ID
      );
    if (
      requestedScopes.includes(SCOPE_OLD_SYNC) &&
      !scope.includes(SCOPE_OLD_SYNC)
    ) {
      throw new Error(ERROR_SYNC_SCOPE_NOT_GRANTED);
    }
    if (scope.includes(SCOPE_OLD_SYNC) && !keys_jwe) {
      throw new Error(ERROR_NO_KEYS_JWE);
    }
    let scopedKeys;
    if (keys_jwe) {
      scopedKeys = JSON.parse(
        new TextDecoder().decode(await lazy.jwcrypto.decryptJWE(keys_jwe, key))
      );
    }

    // We make sure no other flow snuck in, and completed before we did
    if (!this.getFlow(state)) {
      throw new Error(ERROR_OAUTH_FLOW_ABANDONED);
    }

    // Clear all flows, so any in-flight or future flows trigger an error as the browser
    // would have been signed in
    this.clearAllFlows();
    return {
      scopedKeys,
      refreshToken: refresh_token,
      accessToken: access_token,
    };
  }
}