async loginOrCreateAccount()

in packages/fxa-auth-server/lib/routes/linked-accounts.ts [206:511]


  async loginOrCreateAccount(request: AuthRequest) {
    const requestPayload = request.payload as any;

    const provider = requestPayload.provider as Provider;
    const providerId = PROVIDER[provider];
    const service = requestPayload.service;

    // Currently, FxA supports creating a linked account via the oauth authorization flow
    // This flow returns an `id_token` which is used create/get FxA account.
    let idToken: any;
    const code = requestPayload.code;

    const { deviceId, flowId, flowBeginTime } = await request.app
      .metricsContext;

    switch (provider) {
      case 'google': {
        if (!this.googleAuthClient) {
          throw error.thirdPartyAccountError();
        }

        const { clientId, clientSecret, redirectUri } =
          this.config.googleAuthConfig;
        let rawIdToken;
        if (code) {
          const data = {
            code,
            client_id: clientId,
            client_secret: clientSecret,
            redirect_uri: redirectUri,
            grant_type: 'authorization_code',
          };

          try {
            const res = await axios.post(
              this.config.googleAuthConfig.tokenEndpoint,
              data
            );
            // We currently only use the `id_token` after completing the
            // authorization code exchange. In the future we could store a
            // refresh token to do other things like revoking sessions.
            //
            // See https://developers.google.com/identity/protocols/oauth2/openid-connect#exchangecode
            rawIdToken = res.data['id_token'];

            const verifiedToken = await this.googleAuthClient.verifyIdToken({
              idToken: rawIdToken,
              audience: clientId,
            });

            idToken = verifiedToken.getPayload();
          } catch (err) {
            this.log.error('linked_account.code_exchange_error', err);
            throw error.thirdPartyAccountError();
          }
        }
        break;
      }
      case 'apple': {
        const { clientId, keyId, privateKey, teamId } =
          this.config.appleAuthConfig;

        if (!clientId || !keyId || !privateKey || !teamId) {
          throw error.thirdPartyAccountError();
        }

        let rawIdToken;
        const clientSecret = await this.generateAppleClientSecret(
          clientId,
          keyId,
          privateKey,
          teamId
        );
        const code = requestPayload.code;
        if (code) {
          const data = {
            code,
            client_id: clientId,
            client_secret: clientSecret,
            grant_type: 'authorization_code',
          };

          try {
            const res = await axios.post(
              this.config.appleAuthConfig.tokenEndpoint,
              new URLSearchParams(data).toString()
            );
            rawIdToken = res.data['id_token'];
            idToken = jose.decodeJwt(rawIdToken);
          } catch (err) {
            this.log.error('linked_account.code_exchange_error', err);
            throw error.thirdPartyAccountError();
          }
        }
        break;
      }
    }

    if (!idToken) {
      throw error.thirdPartyAccountError();
    }

    const userid = idToken.sub;
    const email = idToken.email;
    const name = idToken.name;

    let accountRecord;
    const linkedAccountRecord = await this.db.getLinkedAccount(
      userid,
      provider
    );

    if (!linkedAccountRecord) {
      // Something has gone wrong! We shouldn't hit a case where we have an unlinked without
      // an email set in the idToken. Failing hard and fast. Logging more info
      if (!email) {
        this.log.error('linked_account.no_email_in_id_token', {
          provider,
          userid,
          name,
        });
        throw error.thirdPartyAccountError();
      }

      try {
        // This is a new third party account linking an existing FxA account
        accountRecord = await this.db.accountRecord(email);
        await this.db.createLinkedAccount(accountRecord.uid, userid, provider);

        if (name) {
          await this.updateProfileDisplayName(accountRecord.uid, name);
        }

        const geoData = request.app.geo;
        const ip = request.app.clientAddress;
        const emailOptions = {
          acceptLanguage: request.app.acceptLanguage,
          deviceId,
          flowId,
          flowBeginTime,
          ip,
          location: geoData.location,
          providerName: PROVIDER_NAME[provider],
          timeZone: geoData.timeZone,
          uaBrowser: request.app.ua.browser,
          uaBrowserVersion: request.app.ua.browserVersion,
          uaOS: request.app.ua.os,
          uaOSVersion: request.app.ua.osVersion,
          uaDeviceType: request.app.ua.deviceType,
          uid: accountRecord.uid,
        };
        await this.mailer.sendPostAddLinkedAccountEmail(
          accountRecord.emails,
          accountRecord,
          emailOptions
        );
        request.setMetricsFlowCompleteSignal('account.login', 'login');
        switch (provider) {
          case 'google':
            await this.glean.thirdPartyAuth.googleLoginComplete(request, {
              reason: 'linking',
            });
            break;
          case 'apple':
            await this.glean.thirdPartyAuth.appleLoginComplete(request, {
              reason: 'linking',
            });
            break;
        }
        await request.emitMetricsEvent('account.login', {
          uid: accountRecord.uid,
          deviceId,
          flowId,
          flowBeginTime,
          service,
        });
      } catch (err) {
        this.log.trace(
          'Account.login.sendPostAddLinkedAccountNotification.error',
          {
            error: err,
          }
        );

        if (err.errno !== error.ERRNO.ACCOUNT_UNKNOWN) {
          throw err;
        }
        // This is a new user creating a new FxA account, we
        // create the FxA account with random password and mark email
        // verified
        const emailCode = await random.hex(16);
        const authSalt = await random.hex(32);
        const [kA, wrapWrapKb, wrapWrapKbVersion2] = await random.hex(
          32,
          32,
          32
        );
        accountRecord = await this.db.createAccount({
          uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'),
          createdAt: Date.now(),
          email,
          emailCode,
          emailVerified: true,
          kA,
          wrapWrapKb,
          wrapWrapKbVersion2,
          authSalt,
          // This will be set with a real value when the users sets an account password.
          clientSalt: undefined,
          verifierVersion: this.config.verifierVersion,
          verifyHash: Buffer.alloc(32).toString('hex'),
          verifyHashVersion2: Buffer.alloc(32).toString('hex'),
          verifierSetAt: 0,
          locale: request.app.acceptLanguage,
        });
        await this.db.createLinkedAccount(accountRecord.uid, userid, provider);

        if (name) {
          await this.updateProfileDisplayName(accountRecord.uid, name);
        }
        // Currently, we treat accounts created from a linked account as a new
        // registration and emit the correspond event. Note that depending on
        // where might not be a top of funnel for this completion event.
        request.setMetricsFlowCompleteSignal(
          'account.verified',
          'registration'
        );
        switch (provider) {
          case 'google':
            await this.glean.thirdPartyAuth.googleRegComplete(request);
            break;
          case 'apple':
            await this.glean.thirdPartyAuth.appleRegComplete(request);
            break;
        }
        await request.emitMetricsEvent('account.verified', {
          uid: accountRecord.uid,
          deviceId,
          flowId,
          flowBeginTime,
          service,
        });
        this.glean.registration.complete(request, { uid: accountRecord.uid });
      }
    } else {
      // This is an existing user and existing FxA user
      accountRecord = await this.db.account(linkedAccountRecord.uid);
      if (service === 'sync') {
        request.setMetricsFlowCompleteSignal('account.signed', 'login');
      } else {
        request.setMetricsFlowCompleteSignal('account.login', 'login');
      }
      await request.emitMetricsEvent('account.login', {
        uid: accountRecord.uid,
        deviceId,
        flowId,
        flowBeginTime,
        service,
      });
      switch (provider) {
        case 'google':
          await this.glean.thirdPartyAuth.googleLoginComplete(request);
          break;
        case 'apple':
          await this.glean.thirdPartyAuth.appleLoginComplete(request);
          break;
      }
    }

    let verificationMethod,
      mustVerifySession = false,
      tokenVerificationId = undefined;
    const hasTotpToken = await this.otpUtils.hasTotpToken(accountRecord);
    if (hasTotpToken) {
      mustVerifySession = true;
      tokenVerificationId = await random.hex(16);
      verificationMethod = 'totp-2fa';
    }

    const sessionTokenOptions = {
      uid: accountRecord.uid,
      email: accountRecord.primaryEmail.email,
      emailCode: accountRecord.primaryEmail.emailCode,
      emailVerified: accountRecord.primaryEmail.isVerified,
      verifierSetAt: accountRecord.verifierSetAt,
      mustVerify: mustVerifySession,
      tokenVerificationId,
      uaBrowser: request.app.ua.browser,
      uaBrowserVersion: request.app.ua.browserVersion,
      uaOS: request.app.ua.os,
      uaOSVersion: request.app.ua.osVersion,
      uaDeviceType: request.app.ua.deviceType,
      uaFormFactor: request.app.ua.formFactor,
      providerId,
    };

    const sessionToken = await this.db.createSessionToken(sessionTokenOptions);

    return {
      uid: sessionToken.uid,
      sessionToken: sessionToken.data,
      providerUid: userid,
      email,
      ...(verificationMethod ? { verificationMethod } : {}),
    };
  }