diff --git a/.circleci/config.yml b/.circleci/config.yml index 01fc695293..0333f14af4 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -71,6 +71,7 @@ executors: environment: NODE_ENV: development FIRESTORE_EMULATOR_HOST: localhost:9090 + CUSTOMS_SERVER_URL: none # Contains minimal image for running common jobs like linting or unit tests. # This image requires a restored workspace state. @@ -85,6 +86,7 @@ executors: environment: NODE_ENV: development FIRESTORE_EMULATOR_HOST: localhost:9090 + CUSTOMS_SERVER_URL: none # A minimal image for anything job needs infrastructure. Perfect for integration tests. # This image requires a restored workspace state. @@ -104,6 +106,7 @@ executors: environment: NODE_ENV: development FIRESTORE_EMULATOR_HOST: localhost:9090 + CUSTOMS_SERVER_URL: none # For anything that needs a full stack to run and needs browsers available for # ui test automation. This image requires a restored workspace state. @@ -141,6 +144,7 @@ executors: REACT_CONVERSION_POST_VERIFY_ADD_RECOVERY_KEY_ROUTES: true REACT_CONVERSION_POST_VERIFY_CAD_VIA_QR_ROUTES: true REACT_CONVERSION_SIGNIN_VERIFICATION_VIA_PUSH_ROUTES: true + CUSTOMS_SERVER_URL: none # Contains a pre-installed fxa stack and browsers for doing ui test # automation. Perfect for running smoke tests against remote targets. @@ -154,6 +158,7 @@ executors: - image: mozilla/fxa-circleci:ci-functional-test-runner environment: NODE_ENV: development + CUSTOMS_SERVER_URL: none commands: diff --git a/packages/fxa-auth-server/README.md b/packages/fxa-auth-server/README.md index 041ad8c003..f7ef9ca3d9 100644 --- a/packages/fxa-auth-server/README.md +++ b/packages/fxa-auth-server/README.md @@ -271,6 +271,12 @@ can be overridden in two ways: export CONFIG_FILES="~/fxa-content-server.json,~/fxa-db.json" ``` +### Rate-limiting config + +Rate-limiting and blocking is handled by fxa-customs-server. By default, these policies are _enabled_ in dev environment via `"customsUrl":"http://localhost:7000"` in `fxa-auth-server/config/dev.json`, but disabled for circleCI. Enabling the customs server allows error messages to be displayed when rate limiting occurs. Default rate-limiting values are found in `fxa-customs-server/lib/config/config.js` and can be modified with environment variables or by adding a `dev.json` file to `fxa-customs-server/config/`. + +The customs-server can be disabled for testing by changing the dev config to `"customsUrl":"none"`. + ### Email config There is also some live config diff --git a/packages/fxa-auth-server/config/dev.json b/packages/fxa-auth-server/config/dev.json index 43908e48f8..b3b90a802b 100644 --- a/packages/fxa-auth-server/config/dev.json +++ b/packages/fxa-auth-server/config/dev.json @@ -2,7 +2,7 @@ "contentServer": { "url": "http://localhost:3030" }, - "customsUrl": "none", + "customsUrl": "http://localhost:7000", "lockoutEnabled": true, "log": { "fmt": "pretty", diff --git a/packages/fxa-customs-server/pm2.config.js b/packages/fxa-customs-server/pm2.config.js index 2a77e57e3a..cdc72467d9 100644 --- a/packages/fxa-customs-server/pm2.config.js +++ b/packages/fxa-customs-server/pm2.config.js @@ -15,6 +15,8 @@ module.exports = { max_restarts: '1', env: { NODE_ENV: 'dev', + // By default, Node18 favours ipv6 for localhost + NODE_OPTIONS: '--dns-result-order=ipv4first', PORT: 7000, PATH, SENTRY_ENV: 'local', diff --git a/packages/fxa-graphql-api/src/gql/lib/error.ts b/packages/fxa-graphql-api/src/gql/lib/error.ts index 360b868c47..7d52310a02 100644 --- a/packages/fxa-graphql-api/src/gql/lib/error.ts +++ b/packages/fxa-graphql-api/src/gql/lib/error.ts @@ -4,6 +4,21 @@ import { ApolloError } from 'apollo-server'; +export class ThrottledError extends ApolloError { + constructor( + message: string, + code: string, + extensions: { + errno: number; + info: string; + retryAfter: number; + retryAfterLocalized: string; + } + ) { + super(message, code, extensions); + } +} + export const PROFILE_INFO_URL = 'https://github.com/mozilla/fxa/blob/main/packages/fxa-profile-server/docs/API.md#errors'; @@ -17,8 +32,22 @@ export function CatchGatewayError( try { return await originalMethod.apply(this, args); } catch (err) { - if (err.code && err.errno && err.message) { - // Auth Server error + if ( + err.code && + err.errno && + err.message && + err.retryAfter && + err.retryAfterLocalized + ) { + // Auth Server error when throttled (too many requests error) + throw new ThrottledError(err.message, err.code, { + errno: err.errno, + info: err.info, + retryAfter: err.retryAfter, + retryAfterLocalized: err.retryAfterLocalized, + }); + } else if (err.code && err.errno && err.message) { + // Auth Server error (general) throw new ApolloError(err.message, err.code, { errno: err.errno, info: err.info, diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index ca59d38c97..00215ced9b 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -4,6 +4,7 @@ import base32Decode from 'base32-decode'; import { gql, ApolloClient, Reference, ApolloError } from '@apollo/client'; +import { ThrottledError } from 'fxa-graphql-api/src/gql/lib/error'; import config from '../lib/config'; import AuthClient, { generateRecoveryKey, @@ -550,11 +551,27 @@ export class Account implements AccountData { `, variables: { input: { email } }, }); - return result.data.passwordForgotSendCode; } catch (err) { - const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno; - if (errno && AuthUiErrorNos[errno]) { + const graphQlError = ((err as ApolloError) || (err as ThrottledError)) + .graphQLErrors[0]; + const errno = graphQlError.extensions?.errno; + if ( + errno && + AuthUiErrorNos[errno] && + errno === AuthUiErrors.THROTTLED.errno + ) { + const throttledErrorWithRetryAfter = { + ...AuthUiErrorNos[errno], + retryAfter: graphQlError.extensions?.retryAfter, + retryAfterLocalized: graphQlError.extensions?.retryAfterLocalized, + }; + throw throttledErrorWithRetryAfter; + } else if ( + errno && + AuthUiErrorNos[errno] && + errno !== AuthUiErrors.THROTTLED.errno + ) { throw AuthUiErrorNos[errno]; } throw AuthUiErrors.UNEXPECTED_ERROR; @@ -613,11 +630,28 @@ export class Account implements AccountData { `, variables: { input: { email } }, }); - return result.data.passwordForgotSendCode; } catch (err) { - const errno = (err as ApolloError).graphQLErrors[0].extensions?.errno; - if (errno && AuthUiErrorNos[errno]) { + const graphQlError = ((err as ApolloError) || (err as ThrottledError)) + .graphQLErrors[0]; + const errno = graphQlError.extensions?.errno; + if ( + (err as ThrottledError) && + errno && + AuthUiErrorNos[errno] && + errno === AuthUiErrors.THROTTLED.errno + ) { + const throttledErrorWithRetryAfter = { + ...AuthUiErrorNos[errno], + retryAfter: graphQlError.extensions?.retryAfter, + retryAfterLocalized: graphQlError.extensions?.retryAfterLocalized, + }; + throw throttledErrorWithRetryAfter; + } else if ( + errno && + AuthUiErrorNos[errno] && + errno !== AuthUiErrors.THROTTLED.errno + ) { throw AuthUiErrorNos[errno]; } throw AuthUiErrors.UNEXPECTED_ERROR; diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx index 64066c60be..a996c47bf1 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/index.stories.tsx @@ -9,6 +9,12 @@ import { Meta } from '@storybook/react'; import { MOCK_ACCOUNT } from '../../models/mocks'; import { MozServices } from '../../lib/types'; import { withLocalization } from '../../../.storybook/decorators'; +import { Account, AppContext } from '../../models'; +import { + mockAccountWithThrottledError, + mockAccountWithUnexpectedError, + mockDefaultAccount, +} from './mocks'; export default { title: 'Pages/ResetPassword', @@ -16,22 +22,35 @@ export default { decorators: [withLocalization], } as Meta; -const storyWithProps = (props?: Partial) => { +const storyWithProps = ( + account: Account, + props?: Partial +) => { const story = () => ( - - - + + + + + ); return story; }; -export const Default = storyWithProps(); +export const Default = storyWithProps(mockDefaultAccount); -export const WithServiceName = storyWithProps({ +export const WithServiceName = storyWithProps(mockDefaultAccount, { serviceName: MozServices.MozillaVPN, }); -export const WithForceAuth = storyWithProps({ +export const WithForceAuth = storyWithProps(mockDefaultAccount, { prefillEmail: MOCK_ACCOUNT.primaryEmail.email, forceAuth: true, }); + +export const WithThrottledErrorOnSubmit = storyWithProps( + mockAccountWithThrottledError +); + +export const WithUnexpectedErrorOnSubmit = storyWithProps( + mockAccountWithUnexpectedError +); diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx index 95bbdc1536..ab6da3d023 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/index.test.tsx @@ -125,7 +125,7 @@ describe('PageResetPassword', () => { ); }); - it('displays errors', async () => { + it('displays unknown account error', async () => { const gqlError: any = AuthUiErrorNos[102]; // Unknown account error const account = { resetPassword: jest.fn().mockRejectedValue(gqlError), @@ -139,4 +139,47 @@ describe('PageResetPassword', () => { await screen.findByText('Unknown account'); }); + + it('displays an error when rate limiting kicks in', async () => { + // mocks an error that contains the required values to localize the message + // does not test if the Account model passes in the correct information + // does not test if the message is localized + // does not test if the lang of localizedRetryAfter matches the lang used for the rest of the string + const gqlThrottledErrorWithRetryAfter: any = { + errno: 114, + message: AuthUiErrorNos[114].message, + retryAfter: 500, + retryAfterLocalized: 'in 15 minutes', + }; // Throttled error + const account = { + resetPassword: jest + .fn() + .mockRejectedValue(gqlThrottledErrorWithRetryAfter), + } as unknown as Account; + + renderWithAccount(account); + + await act(async () => { + fireEvent.click(screen.getByText('Begin Reset')); + }); + + await screen.findByText( + 'You’ve tried too many times. Please try again in 15 minutes.' + ); + }); + + it('handles unexpected errors on submit', async () => { + const unknownError = 'some error'; // for example if the error is not a GQLError + const account = { + resetPassword: jest.fn().mockRejectedValue(unknownError), + } as unknown as Account; + + renderWithAccount(account); + + await act(async () => { + fireEvent.click(screen.getByText('Begin Reset')); + }); + + await screen.findByText('Unexpected error'); + }); }); diff --git a/packages/fxa-settings/src/pages/ResetPassword/index.tsx b/packages/fxa-settings/src/pages/ResetPassword/index.tsx index 54bde3deca..455a25cdb9 100644 --- a/packages/fxa-settings/src/pages/ResetPassword/index.tsx +++ b/packages/fxa-settings/src/pages/ResetPassword/index.tsx @@ -97,16 +97,27 @@ const ResetPassword = ({ }); } catch (err) { let localizedError; - if (err.errno === AuthUiErrors.THROTTLED.errno) { - localizedError = ftlMsgResolver.getMsg( - composeAuthUiErrorTranslationId(err), - AuthUiErrorNos[err.errno].message, - { retryAfter: err.retryAfterLocalized } - ); + if (err.errno && AuthUiErrorNos[err.errno]) { + if ( + err.errno === AuthUiErrors.THROTTLED.errno && + err.retryAfterLocalized + ) { + localizedError = ftlMsgResolver.getMsg( + composeAuthUiErrorTranslationId(err), + AuthUiErrorNos[err.errno].message, + { retryAfter: err.retryAfterLocalized } + ); + } else { + localizedError = ftlMsgResolver.getMsg( + composeAuthUiErrorTranslationId(err), + AuthUiErrorNos[err.errno].message + ); + } } else { + const unexpectedError = AuthUiErrors.UNEXPECTED_ERROR; localizedError = ftlMsgResolver.getMsg( - composeAuthUiErrorTranslationId(err), - err.message + composeAuthUiErrorTranslationId(unexpectedError), + unexpectedError.message ); } setErrorMessage(localizedError); diff --git a/packages/fxa-settings/src/pages/ResetPassword/mocks.tsx b/packages/fxa-settings/src/pages/ResetPassword/mocks.tsx new file mode 100644 index 0000000000..e4a1ee1751 --- /dev/null +++ b/packages/fxa-settings/src/pages/ResetPassword/mocks.tsx @@ -0,0 +1,33 @@ +/* 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 { MOCK_ACCOUNT } from '../../models/mocks'; +import { Account } from '../../models'; + +// No error message on submit +export const mockDefaultAccount = { MOCK_ACCOUNT } as any as Account; + +mockDefaultAccount.resetPassword = () => + Promise.resolve({ passwordForgotToken: 'mockPasswordForgotToken' }); + +// Mocked localized throttled error (not localized in storybook) +export const mockAccountWithThrottledError = { + MOCK_ACCOUNT, +} as unknown as Account; + +const throttledErrorObj = { + errno: 114, + retryAfter: 500, + retryAfterLocalized: 'in 15 minutes', +}; + +mockAccountWithThrottledError.resetPassword = () => + Promise.reject(throttledErrorObj); + +export const mockAccountWithUnexpectedError = { + MOCK_ACCOUNT, +} as unknown as Account; + +mockAccountWithThrottledError.resetPassword = () => + Promise.reject('some error');