diff --git a/libs/accounts/email-sender/.eslintrc.json b/libs/accounts/email-sender/.eslintrc.json new file mode 100644 index 00000000000..3456be9b903 --- /dev/null +++ b/libs/accounts/email-sender/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/accounts/email-sender/README.md b/libs/accounts/email-sender/README.md new file mode 100644 index 00000000000..d3da2882fcd --- /dev/null +++ b/libs/accounts/email-sender/README.md @@ -0,0 +1,11 @@ +# accounts-email-sender + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build accounts-email-senders` to build the library. + +## Running unit tests + +Run `nx test-unit accounts-email-senders` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/accounts/email-sender/jest.config.ts b/libs/accounts/email-sender/jest.config.ts new file mode 100644 index 00000000000..ac0d72e2840 --- /dev/null +++ b/libs/accounts/email-sender/jest.config.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +export default { + displayName: 'accounts-email-sender', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/accounts/email-sender', + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'artifacts/tests/lib/accounts/email-sender', + outputName: 'accounts-email-sender-jest-unit-results.xml', + }, + ], + ], +}; diff --git a/libs/accounts/email-sender/package.json b/libs/accounts/email-sender/package.json new file mode 100644 index 00000000000..d7f86331311 --- /dev/null +++ b/libs/accounts/email-sender/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fxa/accounts/email-sender", + "version": "0.0.1", + "dependencies": {}, + "type": "commonjs", + "main": "./index.cjs", + "types": "./index.d.ts", + "private": true +} diff --git a/libs/accounts/email-sender/project.json b/libs/accounts/email-sender/project.json new file mode 100644 index 00000000000..c83cb1de467 --- /dev/null +++ b/libs/accounts/email-sender/project.json @@ -0,0 +1,37 @@ +{ + "name": "accounts-email-sender", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/accounts/email-sender/src", + "projectType": "library", + "tags": ["scope:shared:lib"], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/accounts/email-sender", + "main": "libs/accounts/email-sender/src/index.ts", + "tsConfig": "libs/accounts/email-sender/tsconfig.lib.json", + "assets": ["libs/accounts/email-sender/*.md"], + "format": ["cjs"], + "generatePackageJson": true + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/accounts/email-sender/jest.config.ts", + "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"] + } + }, + "test-integration": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/accounts/email-sender/jest.config.ts", + "testPathPattern": ["\\.in\\.spec\\.ts$"] + } + } + } +} diff --git a/libs/accounts/email-sender/src/bounces.ts b/libs/accounts/email-sender/src/bounces.ts new file mode 100644 index 00000000000..ef781c2f9bb --- /dev/null +++ b/libs/accounts/email-sender/src/bounces.ts @@ -0,0 +1,106 @@ +/* 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 { AppError } from '@fxa/accounts/errors'; + +export type BouncesConfig = { + enabled: boolean; + hard: Record; + soft: Record; + complaint: Record; + ignoreTemplates: string[]; +}; + +export type Bounce = { + bounceType: number; + createdAt: number; +}; + +export type Tally = { + count: number; + latest: number; +}; + +export type BounceDb = { + emailBounces: { + findByEmail(email: string): Promise>; + }; +}; + +const BOUNCE_TYPE_HARD = 1; +const BOUNCE_TYPE_SOFT = 2; +const BOUNCE_TYPE_COMPLAINT = 3; + +export class Bounces { + private readonly bounceRules: Record; + + constructor( + private readonly config: BouncesConfig, + private readonly db: BounceDb + ) { + this.bounceRules = { + [BOUNCE_TYPE_HARD]: Object.freeze(config.hard || {}), + [BOUNCE_TYPE_SOFT]: Object.freeze(config.soft || {}), + [BOUNCE_TYPE_COMPLAINT]: Object.freeze(config.complaint || {}), + }; + } + + async check(email: string, template: string) { + if (this.config.enabled) { + return await this.checkBounces(email, template); + } + } + + async checkBounces(email: string, template: string) { + if (this.config.ignoreTemplates.includes(template)) { + return; + } + + const bounces = await this.db.emailBounces.findByEmail(email); + this.applyRules(bounces); + } + + private applyRules(bounces: Array) { + const tallies: Record = { + [BOUNCE_TYPE_HARD]: { + count: 0, + latest: 0, + }, + [BOUNCE_TYPE_COMPLAINT]: { + count: 0, + latest: 0, + }, + [BOUNCE_TYPE_SOFT]: { + count: 0, + latest: 0, + }, + }; + const now = Date.now(); + + bounces.forEach((bounce: Bounce) => { + const type = bounce.bounceType; + const ruleSet = this.bounceRules[type]; + if (ruleSet) { + const tally = tallies[type]; + const tier = ruleSet[tally.count]; + if (!tally.latest) { + tally.latest = bounce.createdAt; + } + if (tier && bounce.createdAt > now - tier) { + if (type === BOUNCE_TYPE_HARD) { + throw AppError.emailBouncedHard(tally.latest); + } + if (type === BOUNCE_TYPE_COMPLAINT) { + throw AppError.emailComplaint(tally.latest); + } + if (type === BOUNCE_TYPE_SOFT) { + throw AppError.emailBouncedSoft(tally.latest); + } + } + tally.count++; + } + }); + return tallies; + } +} diff --git a/libs/accounts/email-sender/src/email-sender.ts b/libs/accounts/email-sender/src/email-sender.ts new file mode 100644 index 00000000000..746a19ac97d --- /dev/null +++ b/libs/accounts/email-sender/src/email-sender.ts @@ -0,0 +1,276 @@ +/* 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 { SES } from '@aws-sdk/client-ses'; +import { Bounces } from './bounces'; +import { StatsD } from 'hot-shots'; +import { ILogger } from '@fxa/shared/log'; +import * as nodemailer from 'nodemailer'; +import { isAppError } from '@fxa/accounts/errors'; + +/** + * Config required for the mailer + */ +export type MailerConfig = { + /** SMTP Host */ + host: string; + /** The SMTP server port */ + port: number; + /** If host is 'secured' */ + secure: boolean; + /** If we can ignore TLS, this should only be the case for local dev/test */ + ignoreTLS: boolean; + /** Can connections be pooled */ + pool: boolean; + /** Max number of connections */ + maxConnections: number; + /** Max messages that can be outstanding */ + maxMessages: number; + /** Max rate at which messages can be sent. */ + sendingRate: number; + /** Connectiong timeout for smtp server. */ + connectionTimeout: number; + /** Greeting time out for smtp server. */ + greetingTimeout: number; + /** Socket timeout for smtp server connection. */ + socketTimeout: number; + /** DNS timeout for smtp server connection. */ + dnsTimeout: number; + + /** Optional user name. If not supplied, we fallback to local SES config. */ + user?: string; + /** Optional password. If not supplied, we fallback to local SES config. */ + password?: string; + sesConfigurationSet?: any; + sender: string; +}; + +/** + * Represents an email that can be sent out + */ +export type Email = { + /** Email recipient */ + to: string; + /** Optional cc recipeents */ + cc?: string; + /** Email sender */ + from: string; + /** Name of template used to render email */ + template: string; + /** The version of the template used to render email */ + version: number; + /** Subject of email */ + subject: string; + /** Preview text of email */ + preview: string; + /** HTML content of email */ + html: string; + /** Text content of email */ + text: string; + /** Email headers */ + headers: Record; +}; + +/** + * Sends an email to end end user. + */ +export class EmailSender { + private readonly emailClient: nodemailer.Transporter; + + constructor( + private readonly config: MailerConfig, + private readonly bounces: Bounces, + private readonly statsd: StatsD, + private readonly log: ILogger + ) { + + // Determine auth credentials + const auth = (() => { + + // If the user name and password are set use this + if (config.user && config.password) { + return { + auth: { + user: config.user, + pass: config.password, + }, + }; + } + + // Otherwise fallback to the SES configuration + const ses = new SES({ + // The key apiVersion is no longer supported in v3, and can be removed. + // @deprecated The client uses the "latest" apiVersion. + apiVersion: '2010-12-01', + }); + return { + SES: { ses }, + sendingRate: 5, + maxConnections: 10, + }; + })(); + + // Build node mailer options + const options = { + host: config.host, + secure: config.secure, + ignoreTLS: !config.secure, + port: config.port, + pool: config.pool, + maxConnections: config.maxConnections, + maxMessages: config.maxMessages, + connectionTimeout: config.connectionTimeout, + greetingTimeout: config.greetingTimeout, + socketTimeout: config.socketTimeout, + dnsTimeout: config.dnsTimeout, + sendingRate: this.config.sendingRate, + ...auth, + }; + this.emailClient = nodemailer.createTransport(options); + } + + async buildHeaders({ + template, + context, + headers, + }: { + template: { + name: string; + version: number; + metricsName?: string; + }; + context: { + serverName: string; + language: string; + deviceId?: string; + flowId?: string; + flowBeginTime?: number; + service?: string; + uid?: string; + entrypoint?: string; + cmsRpClientId?: string; + }; + headers: Record; + }) { + const optionalHeader = (key: string, value?: string | number) => { + if (value) { + return { [key]: value.toString() }; + } + return {}; + }; + + headers = { + 'Content-Language': context.language, + 'X-Template-Name': template.name, + 'X-Template-Version': template.version.toString(), + ...headers, + ...optionalHeader('X-Device-Id', context.deviceId), + ...optionalHeader('X-Flow-Id', context.flowId), + ...optionalHeader('X-Flow-Begin-Time', context.flowBeginTime), + ...optionalHeader('X-Service-Id', context.service), + ...optionalHeader('X-Uid', context.uid), + }; + + if (this.config.sesConfigurationSet) { + const sesHeaders = [ + `messageType=fxa-${template.metricsName || template.name}`, + 'app=fxa', + `service=${context.serverName}`, + `ses:feedback-id-a=fxa-${template.name}`, + ]; + if (context.cmsRpClientId && context.entrypoint) { + sesHeaders.push(`cmsRp=${context.cmsRpClientId}-${context.entrypoint}`); + } + + // Note on SES Event Publishing: The X-SES-CONFIGURATION-SET and + // X-SES-MESSAGE-TAGS email headers will be stripped by SES from the + // actual outgoing email messages. + headers['X-SES-CONFIGURATION-SET'] = this.config.sesConfigurationSet; + headers['X-SES-MESSAGE-TAGS'] = sesHeaders.join(', '); + } + + return headers; + } + + async send({ + to, + template, + headers, + from, + cc, + subject, + preview, + text, + html, + }: Email) { + try { + await this.bounces.check(to, template); + } catch (err) { + const tags = { template: template, error: err.errno }; + this.statsd.increment('email.bounce.limit', tags); + + this.log.error('email.bounce.limit', { + err: err.message, + errno: err.errno, + to, + template, + }); + return; + } + + this.log.debug('mailer.send', { + email: to, + template, + headers: Object.keys(headers).join(','), + }); + + // Send email + await new Promise((resolve, reject) => { + const email = { + from, + to, + cc, + subject, + preview, + text, + html, + xMailer: false, + headers, + }; + + this.emailClient.sendMail(email, (err: Error | null, status) => { + if (err) { + if (isAppError(err)) { + this.log.error('mailer.send.error', { + err: err.message, + code: err.code, + errno: err.errno, + message: status && status.message, + to, + template, + }); + } else { + this.log.error('mailer.send.error', { + err: err.message, + message: status && status.message, + to, + template, + }); + } + return reject(err); + } + + this.log.debug('mailer.send', { + status: status && status.message, + id: status && status.messageId, + to, + }); + + // We expose the headers, becuase there's legacy logic in auth-server that + // uses the header values to + return resolve({ email, status }); + }); + }); + } +} diff --git a/libs/accounts/email-sender/src/index.ts b/libs/accounts/email-sender/src/index.ts new file mode 100644 index 00000000000..1a60d6d036a --- /dev/null +++ b/libs/accounts/email-sender/src/index.ts @@ -0,0 +1,6 @@ +/* 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/. */ + +export * from './email-sender'; +export * from './bounces'; diff --git a/libs/accounts/email-sender/tsconfig.json b/libs/accounts/email-sender/tsconfig.json new file mode 100644 index 00000000000..86622aca55a --- /dev/null +++ b/libs/accounts/email-sender/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/accounts/email-sender/tsconfig.lib.json b/libs/accounts/email-sender/tsconfig.lib.json new file mode 100644 index 00000000000..4befa7f0990 --- /dev/null +++ b/libs/accounts/email-sender/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/accounts/email-sender/tsconfig.spec.json b/libs/accounts/email-sender/tsconfig.spec.json new file mode 100644 index 00000000000..69a251f328c --- /dev/null +++ b/libs/accounts/email-sender/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/accounts/errors/.eslintrc.json b/libs/accounts/errors/.eslintrc.json new file mode 100644 index 00000000000..3456be9b903 --- /dev/null +++ b/libs/accounts/errors/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/accounts/errors/README.md b/libs/accounts/errors/README.md new file mode 100644 index 00000000000..55b6779bf1d --- /dev/null +++ b/libs/accounts/errors/README.md @@ -0,0 +1,11 @@ +# account-errors + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build accounts-errors` to build the library. + +## Running unit tests + +Run `nx test-unit accounts-errors` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/accounts/errors/jest.config.ts b/libs/accounts/errors/jest.config.ts new file mode 100644 index 00000000000..7759880e47b --- /dev/null +++ b/libs/accounts/errors/jest.config.ts @@ -0,0 +1,21 @@ +/* eslint-disable */ +export default { + displayName: 'accounts-errors', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/accounts/errors', + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'artifacts/tests/lib/accounts/errors', + outputName: 'accounts-errors-jest-unit-results.xml', + }, + ], + ], +}; diff --git a/libs/accounts/errors/package.json b/libs/accounts/errors/package.json new file mode 100644 index 00000000000..a4033ae3b7f --- /dev/null +++ b/libs/accounts/errors/package.json @@ -0,0 +1,9 @@ +{ + "name": "@fxa/accounts/errors", + "version": "0.0.1", + "dependencies": {}, + "type": "commonjs", + "main": "./index.cjs", + "types": "./index.d.ts", + "private": true +} diff --git a/libs/accounts/errors/project.json b/libs/accounts/errors/project.json new file mode 100644 index 00000000000..312a91894ee --- /dev/null +++ b/libs/accounts/errors/project.json @@ -0,0 +1,37 @@ +{ + "name": "accounts-errors", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/accounts/errors/src", + "projectType": "library", + "tags": ["scope:shared:lib"], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/accounts/errors", + "main": "libs/accounts/errors/src/index.ts", + "tsConfig": "libs/accounts/errors/tsconfig.lib.json", + "assets": ["libs/accounts/errors/*.md"], + "format": ["cjs"], + "generatePackageJson": true + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/accounts/errors/jest.config.ts", + "testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"] + } + }, + "test-integration": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/accounts/errors/jest.config.ts", + "testPathPattern": ["\\.in\\.spec\\.ts$"] + } + } + } +} diff --git a/libs/accounts/errors/src/app-error.ts b/libs/accounts/errors/src/app-error.ts new file mode 100644 index 00000000000..e21f5e0fbe8 --- /dev/null +++ b/libs/accounts/errors/src/app-error.ts @@ -0,0 +1,1840 @@ +/* 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 { + BAD_SIGNATURE_ERRORS, + ERRNO, + ERRNO_REVERSE_MAP, + TOO_LARGE, + DEFAULT_ERRROR, + IGNORED_ERROR_NUMBERS, + DEBUGGABLE_PAYLOAD_KEYS, +} from './constants'; +import { OauthError } from './oauth-error'; +import { Request as HapiRequest } from 'hapi'; + +/** + * Augmented Hapi request. Extends request.app interface auth-server specific feilds that this lib uses. + */ +export type Request = Partial> & { + app: { + acceptLanguage: string; + locale: string; + ua: { + os: string; + osVersion: string; + }; + geo: { + city: string; + state: string; + }; + devices: Promise>; + metricsContext: Promise<{ + service: string; + }>; + }; + route: { + path: string; + }; +}; + +/** + * Type guard to determine if an error is of AppError type. + */ +export function isAppError(error: any): error is AppError { + if ( + typeof error.errno === 'number' && + typeof error.code === 'number' && + typeof error.message === 'string' + ) { + return true; + } + return false; +} + +/** + * Represents known / sanctioned application errors. + */ +export class AppError extends Error { + // Rereference ERRNO for backward compatibility reasons + static readonly ERRNO = ERRNO; + + /** Hapi specific field. Indicating unhandled error.*/ + public isBoom: boolean; + + /** VError specific field. Used for wrapping errors. */ + public jse_cause?: AppError | Error; + + /** The standardized error number. See ERRNO's. */ + public errno: number; + + /** The status code. Maps to http status code. */ + public code: number; + + /** Output state written to response object. */ + public output: { + statusCode: number; + payload: { + code: number; + errno: number; + error?: Error; + message: string; + info?: string; + request?: any; + log?: any; + data?: any; + retryAfter?: number; + retryAfterLocalized?: string; + }; + headers: Record; + }; + + constructor( + options: any, + extra?: Record, + headers?: Record, + error?: AppError | Error + ) { + super(options.message || DEFAULT_ERRROR.message); + this.isBoom = true; + this.stack = options.stack; + if (!this.stack) { + Error.captureStackTrace(this, AppError); + } + if (error) { + // This is where verror stores the error cause passed in. + this.jse_cause = error; + } + this.code = options.code || DEFAULT_ERRROR.code; + this.errno = options.errno || DEFAULT_ERRROR.errno; + this.output = { + statusCode: options.code || DEFAULT_ERRROR.code, + payload: { + code: options.code || DEFAULT_ERRROR.code, + errno: this.errno, + error: options.error || DEFAULT_ERRROR.error, + message: this.message, + info: options.info || DEFAULT_ERRROR.info, + retryAfter: extra?.['retryAfter'], + retryAfterLocalized: extra?.['retryAfterLocalized'], + }, + headers: headers || {}, + }; + Object.assign(this.output.payload, extra || {}); + } + + override toString() { + return `Error: ${this.message}`; + } + + header(name: string, value: string) { + this.output.headers[name] = value; + } + + backtrace(traced: string) { + this.output.payload.log = traced; + } + + /** + Translates an error from Hapi format to our format + */ + static translate( + request: Request, + response: + | AppError + | OauthError + | { + output: { + payload?: any; + }; + reason?: string; + stack?: string; + data?: any; + }, + oauthRoutes: Array<{ path: string; config: { cors: boolean } }> + ) { + let error; + if (response instanceof AppError) { + console.log('!!! hmm'); + return response; + } + + if ( + request?.route?.path && + OauthError.isOauthRoute(request.route.path, oauthRoutes) + ) { + console.log('!!! oauth translate', request?.route?.path); + return OauthError.translate(response); + } else if (response instanceof OauthError) { + console.log('!!! app error translate'); + return AppError.appErrorFromOauthError(response); + } + const payload = response.output.payload; + const reason = response.reason; + if (!payload) { + error = AppError.unexpectedError(request); + } else if ( + payload.statusCode === 500 && + reason && + /(socket hang up|ECONNREFUSED)/.test(reason) + ) { + // A connection to a remote service either was not made or timed out. + if (response instanceof Error) { + error = AppError.backendServiceFailure( + undefined, + undefined, + undefined, + response + ); + } else { + error = AppError.backendServiceFailure(); + } + } else if (payload.statusCode === 401) { + // These are common errors generated by Hawk auth lib. + if ( + payload.message === 'Unknown credentials' || + payload.message === 'Invalid credentials' + ) { + error = AppError.invalidToken( + `Invalid authentication token: ${payload.message}` + ); + } else if (payload.message === 'Stale timestamp') { + error = AppError.invalidTimestamp(); + } else if (payload.message === 'Invalid nonce') { + error = AppError.invalidNonce(); + } else if (BAD_SIGNATURE_ERRORS.indexOf(payload.message) !== -1) { + error = AppError.invalidSignature(payload.message); + } else { + error = AppError.invalidToken( + `Invalid authentication token: ${payload.message}` + ); + } + } else if (payload.validation) { + if (payload.message?.includes('is required')) { + error = AppError.missingRequestParameter(payload.validation.keys[0]); + } else { + error = AppError.invalidRequestParameter(payload.validation); + } + } else if (payload.statusCode === 413 && TOO_LARGE.test(payload.message)) { + error = AppError.requestBodyTooLarge(); + } else { + error = new AppError({ + message: payload.message, + code: payload.statusCode, + error: payload.error, + errno: payload.errno, + info: payload.info, + stack: response.stack, + }); + + if (response.data) { + error.output.payload.data = JSON.stringify(response.data); + } + + if (payload.statusCode >= 500) { + AppError.decorateErrorWithRequest(error, request); + } + } + return error; + } + + static mapErrnoToKey(error: { errno: number }) { + const errno = error?.errno; + return ERRNO_REVERSE_MAP[errno]; + } + + // Helper functions for creating particular response types. + static dbIncorrectPatchLevel(level: string, levelRequired: boolean) { + return new AppError( + { + code: 400, + error: 'Server Startup', + errno: ERRNO.SERVER_CONFIG_ERROR, + message: 'Incorrect Database Patch Level', + }, + { + level: level, + levelRequired: levelRequired, + } + ); + } + + static backendServiceFailure( + service?: string, + operation?: string, + extra?: Record, + error?: Error + ) { + if (extra) { + return new AppError( + { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.BACKEND_SERVICE_FAILURE, + message: 'System unavailable, try again soon', + }, + { + service, + operation, + ...extra, + }, + {}, + error + ); + } + return new AppError( + { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.BACKEND_SERVICE_FAILURE, + message: 'System unavailable, try again soon', + }, + { + service, + operation, + }, + {}, + error + ); + } + + static accountExists(email: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.ACCOUNT_EXISTS, + message: 'Account already exists', + }, + { + email: email, + } + ); + } + + static unknownAccount(email: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.ACCOUNT_UNKNOWN, + message: 'Unknown account', + }, + { + email: email, + } + ); + } + + static incorrectPassword(dbEmail: string, requestEmail: string) { + if (dbEmail !== requestEmail) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INCORRECT_EMAIL_CASE, + message: 'Incorrect email case', + }, + { + email: dbEmail, + } + ); + } + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INCORRECT_PASSWORD, + message: 'Incorrect password', + }, + { + email: dbEmail, + } + ); + } + + static cannotCreatePassword() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.CANNOT_CREATE_PASSWORD, + message: 'Can not create password, password already set.', + }); + } + + static cannotLoginNoPasswordSet() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.UNABLE_TO_LOGIN_NO_PASSWORD_SET, + message: 'Complete account setup, please reset password to continue.', + }); + } + + static unverifiedAccount() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.ACCOUNT_UNVERIFIED, + message: 'Unconfirmed account', + }); + } + + static invalidVerificationCode(details: Record) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_VERIFICATION_CODE, + message: 'Invalid confirmation code', + }, + details + ); + } + + static invalidRequestBody() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_JSON, + message: 'Invalid JSON in request body', + }); + } + + static invalidRequestParameter(validation: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_PARAMETER, + message: 'Invalid parameter in request body', + }, + { + validation: validation, + } + ); + } + + static missingRequestParameter(param: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.MISSING_PARAMETER, + message: `Missing parameter in request body${param ? `: ${param}` : ''}`, + }, + { + param: param, + } + ); + } + + static invalidSignature(message?: string) { + return new AppError({ + code: 401, + error: 'Unauthorized', + errno: ERRNO.INVALID_REQUEST_SIGNATURE, + message: message || 'Invalid request signature', + }); + } + + static invalidToken(message?: string) { + return new AppError({ + code: 401, + error: 'Unauthorized', + errno: ERRNO.INVALID_TOKEN, + message: message || 'Invalid authentication token in request signature', + }); + } + + static invalidTimestamp() { + return new AppError( + { + code: 401, + error: 'Unauthorized', + errno: ERRNO.INVALID_TIMESTAMP, + message: 'Invalid timestamp in request signature', + }, + { + serverTime: Math.floor(+new Date() / 1000), + } + ); + } + + static invalidNonce() { + return new AppError({ + code: 401, + error: 'Unauthorized', + errno: ERRNO.INVALID_NONCE, + message: 'Invalid nonce in request signature', + }); + } + + static unauthorized(reason: string) { + return new AppError( + { + code: 401, + error: 'Unauthorized', + errno: ERRNO.INVALID_TOKEN, + message: 'Unauthorized for route', + }, + { + detail: reason, + } + ); + } + + static missingContentLength() { + return new AppError({ + code: 411, + error: 'Length Required', + errno: ERRNO.MISSING_CONTENT_LENGTH_HEADER, + message: 'Missing content-length header', + }); + } + + static requestBodyTooLarge() { + return new AppError({ + code: 413, + error: 'Request Entity Too Large', + errno: ERRNO.REQUEST_TOO_LARGE, + message: 'Request body too large', + }); + } + + static tooManyRequests( + retryAfter: number, + retryAfterLocalized?: string, + canUnblock?: boolean + ) { + if (!retryAfter) { + retryAfter = 30; + } + + const extraData: any = { + retryAfter: retryAfter, + }; + + if (retryAfterLocalized) { + extraData.retryAfterLocalized = retryAfterLocalized; + } + + if (canUnblock) { + extraData.verificationMethod = 'email-captcha'; + extraData.verificationReason = 'login'; + } + + const error = new AppError( + { + code: 429, + error: 'Too Many Requests', + errno: ERRNO.THROTTLED, + message: 'Client has sent too many requests', + }, + extraData, + { + 'retry-after': retryAfter.toString(), + } + ); + + return error; + } + + static requestBlocked(canUnblock: boolean) { + let extra; + if (canUnblock) { + extra = { + verificationMethod: 'email-captcha', + verificationReason: 'login', + }; + } + return new AppError( + { + code: 400, + error: 'Request blocked', + errno: ERRNO.REQUEST_BLOCKED, + message: 'The request was blocked for security reasons', + }, + extra + ); + } + + static serviceUnavailable(retryAfter: number) { + if (!retryAfter) { + retryAfter = 30; + } + return new AppError( + { + code: 503, + error: 'Service Unavailable', + errno: ERRNO.SERVER_BUSY, + message: 'Service unavailable', + }, + { + retryAfter: retryAfter, + }, + { + 'retry-after': retryAfter.toString(), + } + ); + } + + static featureNotEnabled(retryAfter: number) { + if (!retryAfter) { + retryAfter = 30; + } + return new AppError( + { + code: 503, + error: 'Feature not enabled', + errno: ERRNO.FEATURE_NOT_ENABLED, + message: 'Feature not enabled', + }, + { + retryAfter: retryAfter, + }, + { + 'retry-after': retryAfter.toString(), + } + ); + } + + static gone() { + return new AppError({ + code: 410, + error: 'Gone', + errno: ERRNO.ENDPOINT_NOT_SUPPORTED, + message: 'This endpoint is no longer supported', + }); + } + + static goneFourOhFour() { + return new AppError({ + code: 404, + error: 'Gone', + errno: ERRNO.ENDPOINT_NOT_SUPPORTED, + message: 'This endpoint is no longer supported', + }); + } + + static mustResetAccount(email: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.ACCOUNT_RESET, + message: 'Account must be reset', + }, + { + email: email, + } + ); + } + + static unknownDevice() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.DEVICE_UNKNOWN, + message: 'Unknown device', + }); + } + + static deviceSessionConflict(deviceId: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.DEVICE_CONFLICT, + message: 'Session already registered by another device', + }, + { deviceId } + ); + } + + static invalidUnblockCode() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_UNBLOCK_CODE, + message: 'Invalid unblock code', + }); + } + + static invalidPhoneNumber() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_PHONE_NUMBER, + message: 'Invalid phone number', + }); + } + + static recoveryCodesAlreadyExist() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_CODES_ALREADY_EXISTS, + message: 'Recovery codes or a verified TOTP token already exist', + }); + } + + static recoveryPhoneNumberAlreadyExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_PHONE_NUMBER_ALREADY_EXISTS, + message: 'Recovery phone number already exists', + }); + } + + static recoveryPhoneNumberDoesNotExist() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_PHONE_NUMBER_DOES_NOT_EXIST, + message: 'Recovery phone number does not exist', + }); + } + + static recoveryPhoneRemoveMissingRecoveryCodes() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_PHONE_REMOVE_MISSING_RECOVERY_CODES, + message: + 'Unable to remove recovery phone, missing backup authentication codes.', + }); + } + + static smsSendRateLimitExceeded() { + return new AppError({ + code: 429, + error: 'Too many requests', + errno: ERRNO.SMS_SEND_RATE_LIMIT_EXCEEDED, + message: 'Text message limit reached', + }); + } + + static recoveryPhoneRegistrationLimitReached() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_PHONE_REGISTRATION_LIMIT_REACHED, + message: + 'Limit reached for number off accounts that can be associated with phone number.', + }); + } + + static invalidRegion(region: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_REGION, + message: 'Invalid region', + }, + { + region, + } + ); + } + + static unsupportedLocation(country: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.UNSUPPORTED_LOCATION, + message: 'Location is not supported according to our Terms of Service.', + }, + { + country, + } + ); + } + + static currencyCountryMismatch(currency: string, country: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_REGION, + message: 'Funding source country does not match plan currency.', + }, + { + currency, + country, + } + ); + } + + static currencyCurrencyMismatch(currencyA: string, currencyB: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_CURRENCY, + message: `Changing currencies is not permitted.`, + }, + { + currencyA, + currencyB, + } + ); + } + + static billingAgreementExists(customerId: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.BILLING_AGREEMENT_EXISTS, + message: `Billing agreement already on file for this customer.`, + }, + { + customerId, + } + ); + } + + static missingPaypalPaymentToken(customerId: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.MISSING_PAYPAL_PAYMENT_TOKEN, + message: `PayPal payment token is missing.`, + }, + { + customerId, + } + ); + } + + static missingPaypalBillingAgreement(customerId: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.MISSING_PAYPAL_BILLING_AGREEMENT, + message: `PayPal billing agreement is missing for the existing subscriber.`, + }, + { + customerId, + } + ); + } + + static invalidMessageId() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_MESSAGE_ID, + message: 'Invalid message id', + }); + } + + static messageRejected(reason: string, reasonCode: number) { + return new AppError( + { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.MESSAGE_REJECTED, + message: 'Message rejected', + }, + { + reason, + reasonCode, + } + ); + } + + static emailComplaint(bouncedAt: number) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.BOUNCE_COMPLAINT, + message: 'Email account sent complaint', + }, + { + bouncedAt, + } + ); + } + + static emailBouncedHard(bouncedAt: number) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.BOUNCE_HARD, + message: 'Email account hard bounced', + }, + { + bouncedAt, + } + ); + } + + static emailBouncedSoft(bouncedAt: number) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.BOUNCE_SOFT, + message: 'Email account soft bounced', + }, + { + bouncedAt, + } + ); + } + + static emailExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.EMAIL_EXISTS, + message: 'Email already exists', + }); + } + + static cannotDeletePrimaryEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.EMAIL_DELETE_PRIMARY, + message: 'Can not delete primary email', + }); + } + + static unverifiedSession() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.SESSION_UNVERIFIED, + message: 'Unconfirmed session', + }); + } + + static yourPrimaryEmailExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.USER_PRIMARY_EMAIL_EXISTS, + message: 'Can not add secondary email that is same as your primary', + }); + } + + static verifiedPrimaryEmailAlreadyExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.VERIFIED_PRIMARY_EMAIL_EXISTS, + message: 'Email already exists', + }); + } + + static verifiedSecondaryEmailAlreadyExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.VERIFIED_SECONDARY_EMAIL_EXISTS, + message: 'Email already exists', + }); + } + + // This error is thrown when someone attempts to add a secondary email + // that is the same as the primary email of another account, but the account + // was recently created ( < 24hrs). + static unverifiedPrimaryEmailNewlyCreated() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.UNVERIFIED_PRIMARY_EMAIL_NEWLY_CREATED, + message: 'Email already exists', + }); + } + + static unverifiedPrimaryEmailHasActiveSubscription() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.UNVERIFIED_PRIMARY_EMAIL_HAS_ACTIVE_SUBSCRIPTION, + message: 'Account for this email has an active subscription', + }); + } + + static maxSecondaryEmailsReached() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.MAX_SECONDARY_EMAILS_REACHED, + message: 'You have reached the maximum allowed secondary emails', + }); + } + + static alreadyOwnsEmail() { + return new AppError({ + code: 400, + error: 'Conflict', + errno: ERRNO.ACCOUNT_OWNS_EMAIL, + message: 'This email already exists on your account', + }); + } + + static cannotLoginWithSecondaryEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.LOGIN_WITH_SECONDARY_EMAIL, + message: 'Sign in with this email type is not currently supported', + }); + } + + static unknownSecondaryEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.SECONDARY_EMAIL_UNKNOWN, + message: 'Unknown email', + }); + } + + static cannotResetPasswordWithSecondaryEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RESET_PASSWORD_WITH_SECONDARY_EMAIL, + message: 'Reset password with this email type is not currently supported', + }); + } + + static invalidSigninCode() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_SIGNIN_CODE, + message: 'Invalid signin code', + }); + } + + static cannotChangeEmailToUnverifiedEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.CHANGE_EMAIL_TO_UNVERIFIED_EMAIL, + message: 'Can not change primary email to an unconfirmed email', + }); + } + + static cannotChangeEmailToUnownedEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.CHANGE_EMAIL_TO_UNOWNED_EMAIL, + message: + 'Can not change primary email to an email that does not belong to this account', + }); + } + + static cannotLoginWithEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.LOGIN_WITH_INVALID_EMAIL, + message: 'This email can not currently be used to login', + }); + } + + static cannotResendEmailCodeToUnownedEmail() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RESEND_EMAIL_CODE_TO_UNOWNED_EMAIL, + message: + 'Can not resend email code to an email that does not belong to this account', + }); + } + + static cannotSendEmail(isNewAddress: boolean) { + if (!isNewAddress) { + return new AppError({ + code: 500, + error: 'Internal Server Error', + errno: ERRNO.FAILED_TO_SEND_EMAIL, + message: 'Failed to send email', + }); + } + return new AppError({ + code: 422, + error: 'Unprocessable Entity', + errno: ERRNO.FAILED_TO_SEND_EMAIL, + message: 'Failed to send email', + }); + } + + static invalidTokenVerficationCode(details?: Record) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_TOKEN_VERIFICATION_CODE, + message: 'Invalid token confirmation code', + }, + details + ); + } + + static expiredTokenVerficationCode(details?: Record) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.EXPIRED_TOKEN_VERIFICATION_CODE, + message: 'Expired token confirmation code', + }, + details + ); + } + + static totpTokenAlreadyExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.TOTP_TOKEN_EXISTS, + message: 'TOTP token already exists for this account.', + }); + } + + static totpTokenDoesNotExist() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.TOTP_SECRET_DOES_NOT_EXIST, + message: 'TOTP secret does not exist for this account.', + }); + } + + static totpTokenNotFound() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.TOTP_TOKEN_NOT_FOUND, + message: 'TOTP token not found.', + }); + } + + static recoveryCodeNotFound() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_CODE_NOT_FOUND, + message: 'Backup authentication code not found.', + }); + } + + static unavailableDeviceCommand() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.DEVICE_COMMAND_UNAVAILABLE, + message: 'Unavailable device command.', + }); + } + + static recoveryKeyNotFound() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_KEY_NOT_FOUND, + message: 'Account recovery key not found.', + }); + } + + static recoveryKeyInvalid() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_KEY_INVALID, + message: 'Account recovery key is not valid.', + }); + } + + static totpRequired() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.TOTP_REQUIRED, + message: + 'This request requires two step authentication enabled on your account.', + }); + } + + static recoveryKeyExists() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.RECOVERY_KEY_EXISTS, + message: 'Account recovery key already exists.', + }); + } + + static unknownClientId(clientId?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.UNKNOWN_CLIENT_ID, + message: 'Unknown client_id', + }, + { + clientId, + } + ); + } + + static incorrectClientSecret(clientId?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INCORRECT_CLIENT_SECRET, + message: 'Incorrect client_secret', + }, + { + clientId, + } + ); + } + + static staleAuthAt(authAt: number) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.STALE_AUTH_AT, + message: 'Stale auth timestamp', + }, + { + authAt, + } + ); + } + + static notPublicClient() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.NOT_PUBLIC_CLIENT, + message: 'Not a public client', + }); + } + + static redisConflict() { + return new AppError({ + code: 409, + error: 'Conflict', + errno: ERRNO.REDIS_CONFLICT, + message: 'Redis WATCH detected a conflicting update', + }); + } + + static incorrectRedirectURI(redirectUri?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INCORRECT_REDIRECT_URI, + message: 'Incorrect redirect URI', + }, + { + redirectUri, + } + ); + } + + static unknownAuthorizationCode(code?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.UNKNOWN_AUTHORIZATION_CODE, + message: 'Unknown authorization code', + }, + { + code, + } + ); + } + + static mismatchAuthorizationCode(code?: string, clientId?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.MISMATCH_AUTHORIZATION_CODE, + message: 'Mismatched authorization code', + }, + { + code, + clientId, + } + ); + } + + static expiredAuthorizationCode(code?: string, expiredAt?: number) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.EXPIRED_AUTHORIZATION_CODE, + message: 'Expired authorization code', + }, + { + code, + expiredAt, + } + ); + } + + static invalidResponseType() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_RESPONSE_TYPE, + message: 'Invalid response_type', + }); + } + + static invalidScopes(invalidScopes?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_SCOPES, + message: 'Requested scopes are not allowed', + }, + { + invalidScopes, + } + ); + } + + static missingPkceParameters() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.MISSING_PKCE_PARAMETERS, + message: 'Public clients require PKCE OAuth parameters', + }); + } + + static invalidPkceChallenge(pkceHashValue?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_PKCE_CHALLENGE, + message: 'Public clients require PKCE OAuth parameters', + }, + { + pkceHashValue, + } + ); + } + + static invalidPromoCode(promotionCode?: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_PROMOTION_CODE, + message: 'Invalid promotion code', + }, + { + promotionCode, + } + ); + } + + static unknownCustomer(uid?: string) { + return new AppError( + { + code: 404, + error: 'Not Found', + errno: ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER, + message: 'Unknown customer', + }, + { + uid, + } + ); + } + + static unknownSubscription(subscriptionId: string) { + return new AppError( + { + code: 404, + error: 'Not Found', + errno: ERRNO.UNKNOWN_SUBSCRIPTION, + message: 'Unknown subscription', + }, + { + subscriptionId, + } + ); + } + + static unknownAppName(appName: string) { + return new AppError( + { + code: 404, + error: 'Not Found', + errno: ERRNO.IAP_UNKNOWN_APPNAME, + message: 'Unknown app name', + }, + { + appName, + } + ); + } + + static unknownSubscriptionPlan(planId: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.UNKNOWN_SUBSCRIPTION_PLAN, + message: 'Unknown subscription plan', + }, + { + planId, + } + ); + } + + static rejectedSubscriptionPaymentToken( + message: string, + paymentError: Error + ) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN, + message, + }, + paymentError + ); + } + + static rejectedCustomerUpdate(message: string, paymentError: Error) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.REJECTED_CUSTOMER_UPDATE, + message, + }, + paymentError + ); + } + + static subscriptionAlreadyCancelled() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.SUBSCRIPTION_ALREADY_CANCELLED, + message: 'Subscription has already been cancelled', + }); + } + + static invalidPlanUpdate() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_PLAN_UPDATE, + message: 'Subscription plan is not a valid update', + }); + } + + static paymentFailed() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.PAYMENT_FAILED, + message: 'Payment method failed', + }); + } + + static subscriptionAlreadyChanged() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.SUBSCRIPTION_ALREADY_CHANGED, + message: 'Subscription has already been cancelled', + }); + } + + static subscriptionAlreadyExists() { + return new AppError({ + code: 409, + error: 'Already subscribed', + errno: ERRNO.SUBSCRIPTION_ALREADY_EXISTS, + message: 'User already subscribed.', + }); + } + + static userAlreadySubscribedToProduct() { + return new AppError({ + code: 409, + error: 'Already subscribed to product with different plan', + errno: ERRNO.SUBSCRIPTION_ALREADY_EXISTS, + message: 'User already subscribed to product with different plan.', + }); + } + + static iapInvalidToken(error?: Error) { + const extra = error ? [{}, undefined, error] : []; + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.IAP_INVALID_TOKEN, + message: `Invalid IAP token${error?.message ? `: ${error.message}` : ''}`, + }, + ...extra + ); + } + + static iapPurchaseConflict(error: Error) { + const extra = error ? [{}, undefined, error] : []; + return new AppError( + { + code: 403, + error: 'Forbidden', + errno: ERRNO.IAP_PURCHASE_ALREADY_REGISTERED, + message: 'Purchase has been registered to another user.', + }, + ...extra + ); + } + + static invalidInvoicePreviewRequest( + error: Error, + message: string, + priceId: string, + customer: string + ) { + const extra = error ? [{}, undefined, error] : []; + return new AppError( + { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.INVALID_INVOICE_PREVIEW_REQUEST, + message, + }, + { + priceId, + customer, + }, + ...extra + ); + } + + static iapInternalError(error: Error) { + const extra = error ? [{}, undefined, error] : []; + return new AppError( + { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.IAP_INTERNAL_OTHER, + message: 'IAP Internal Error', + }, + ...extra + ); + } + + static insufficientACRValues(foundValue: string) { + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.INSUFFICIENT_ACR_VALUES, + message: + 'Required Authentication Context Reference values could not be satisfied', + }, + { + foundValue, + } + ); + } + + static unknownRefreshToken() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.REFRESH_TOKEN_UNKNOWN, + message: 'Unknown refresh token', + }); + } + + static invalidOrExpiredOtpCode() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_EXPIRED_OTP_CODE, + message: 'Invalid or expired confirmation code', + }); + } + + static thirdPartyAccountError() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.THIRD_PARTY_ACCOUNT_ERROR, + message: + 'Could not login with third party account, please try again later', + }); + } + + static disabledClientId(clientId: string, retryAfter?: number) { + if (!retryAfter) { + retryAfter = 30; + } + return new AppError( + { + code: 503, + error: 'Client Disabled', + errno: ERRNO.DISABLED_CLIENT_ID, + message: 'This client has been temporarily disabled', + }, + { + clientId, + retryAfter, + }, + { + 'retry-after': retryAfter.toString(), + } + ); + } + + static internalValidationError(op: string, data: unknown, error: Error) { + return new AppError( + { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.INTERNAL_VALIDATION_ERROR, + message: 'An internal validation check failed.', + }, + { + op, + data, + }, + {}, + error + ); + } + + static unexpectedError(request?: Request) { + const error = new AppError({}); + AppError.decorateErrorWithRequest(error, request); + return error; + } + + static missingSubscriptionForSourceError(op: string, data: any) { + return new AppError( + { + code: 500, + error: 'Missing subscription for source', + errno: ERRNO.UNKNOWN_SUBSCRIPTION_FOR_SOURCE, + message: 'Failed to find a subscription associated with Stripe source.', + }, + { + op, + data, + } + ); + } + + static accountCreationRejected() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.ACCOUNT_CREATION_REJECTED, + message: 'Account creation rejected.', + }); + } + + static subscriptionPromotionCodeNotApplied(error?: Error, message?: string) { + const extra = error ? [{}, undefined, error] : []; + return new AppError( + { + code: 400, + error: 'Bad Request', + errno: ERRNO.SUBSCRIPTION_PROMO_CODE_NOT_APPLIED, + message, + }, + ...extra + ); + } + + static invalidCloudTaskEmailType() { + return new AppError({ + code: 400, + error: 'Bad Request', + errno: ERRNO.INVALID_CLOUDTASK_EMAILTYPE, + message: 'Invalid email type', + }); + } + + private static decorateErrorWithRequest(error: AppError, request?: Request) { + if (request) { + error.output.payload.request = { + // request.app.devices and request.app.metricsContext are async, so can't be included here + acceptLanguage: request.app.acceptLanguage, + locale: request.app.locale, + userAgent: request.app.ua, + method: request.method, + path: request.path, + query: request.query, + payload: scrubPii(request.payload), + headers: scrubHeaders(request.headers), + }; + } + } + + private static appErrorFromOauthError(err: any) { + switch (err.errno) { + case 101: + return AppError.unknownClientId(err.clientId); + case 102: + return AppError.incorrectClientSecret(err.clientId); + case 103: + return AppError.incorrectRedirectURI(err.redirectUri); + case 104: + return AppError.invalidToken(); + case 105: + return AppError.unknownAuthorizationCode(err.code); + case 106: + return AppError.mismatchAuthorizationCode(err.code, err.clientId); + case 107: + return AppError.expiredAuthorizationCode(err.code, err.expiredAt); + case 108: + return AppError.invalidToken(); + case 109: + return AppError.invalidRequestParameter(err.validation); + case 110: + return AppError.invalidResponseType(); + case 114: + return AppError.invalidScopes(err.invalidScopes); + case 116: + return AppError.notPublicClient(); + case 117: + return AppError.invalidPkceChallenge(err.pkceHashValue); + case 118: + return AppError.missingPkceParameters(); + case 119: + return AppError.staleAuthAt(err.authAt); + case 120: + return AppError.insufficientACRValues(err.foundValue); + case 121: + return AppError.invalidRequestParameter('grant_type'); + case 122: + return AppError.unknownRefreshToken(); + case 201: + return AppError.serviceUnavailable(err.retryAfter); + case 202: + return AppError.disabledClientId(err.clientId); + default: + return err; + } + } +} + +/** + * Tries to remove PII from payload data. + * @param payload + * @returns + */ +function scrubPii(payload: any) { + if (!payload) { + return; + } + + return Object.entries(payload).reduce((scrubbed: any, [key, value]) => { + if (DEBUGGABLE_PAYLOAD_KEYS.has(key)) { + scrubbed[key] = value; + } + + return scrubbed; + }, {}); +} + +/** + * Deletes feilds with senstive data from headers. + * @param headers + * @returns + */ +function scrubHeaders( + headers?: Record +): Record { + if (headers == null) { + return {}; + } + + const scrubbed = { ...headers }; + delete scrubbed['x-forwarded-for']; + return scrubbed; +} + +/** + * Prevents errors from being captured by Sentry. + * + * @param {Error} error An error with an error number. Note that errors of type vError will + * use the underlying jse_cause error if possible. + */ +export function ignoreErrors(error: AppError) { + if (!error) { + return; + } + + // Prefer jse_cause, but fallback to top level error if needed + const statusCode = + determineStatusCode(error.jse_cause) || determineStatusCode(error); + + const errno = (() => { + if (error && error.jse_cause && 'errno' in error.jse_cause) { + return error.jse_cause.errno; + } + return error.errno; + })(); + + // Ignore non 500 status codes and specific error numbers + return ( + (statusCode && statusCode < 500) || IGNORED_ERROR_NUMBERS.includes(errno) + ); +} + +/** + * Given an error tries to determine the HTTP status code associated with the error. + * @param {*} error + * @returns + */ + +/** + * Uses some fallback logic to determine an error's underlying HTTP status code. + * @param error + * @returns + */ +function determineStatusCode(error?: any) { + if (!error) { + return; + } + + return error.statusCode || error.output?.statusCode || error.code; +} diff --git a/libs/accounts/errors/src/constants.ts b/libs/accounts/errors/src/constants.ts new file mode 100644 index 00000000000..36f41094b57 --- /dev/null +++ b/libs/accounts/errors/src/constants.ts @@ -0,0 +1,220 @@ +/* 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/. */ + +/** + * Standard error numbers + */ +export const ERRNO = { + SERVER_CONFIG_ERROR: 100, + ACCOUNT_EXISTS: 101, + ACCOUNT_UNKNOWN: 102, + INCORRECT_PASSWORD: 103, + ACCOUNT_UNVERIFIED: 104, + INVALID_VERIFICATION_CODE: 105, + INVALID_JSON: 106, + INVALID_PARAMETER: 107, + MISSING_PARAMETER: 108, + INVALID_REQUEST_SIGNATURE: 109, + INVALID_TOKEN: 110, + INVALID_TIMESTAMP: 111, + MISSING_CONTENT_LENGTH_HEADER: 112, + REQUEST_TOO_LARGE: 113, + THROTTLED: 114, + INVALID_NONCE: 115, + ENDPOINT_NOT_SUPPORTED: 116, + INCORRECT_EMAIL_CASE: 120, + // ACCOUNT_LOCKED: 121, + // ACCOUNT_NOT_LOCKED: 122, + DEVICE_UNKNOWN: 123, + DEVICE_CONFLICT: 124, + REQUEST_BLOCKED: 125, + ACCOUNT_RESET: 126, + INVALID_UNBLOCK_CODE: 127, + // MISSING_TOKEN: 128, + INVALID_PHONE_NUMBER: 129, + INVALID_REGION: 130, + INVALID_MESSAGE_ID: 131, + MESSAGE_REJECTED: 132, + BOUNCE_COMPLAINT: 133, + BOUNCE_HARD: 134, + BOUNCE_SOFT: 135, + EMAIL_EXISTS: 136, + EMAIL_DELETE_PRIMARY: 137, + SESSION_UNVERIFIED: 138, + USER_PRIMARY_EMAIL_EXISTS: 139, + VERIFIED_PRIMARY_EMAIL_EXISTS: 140, + MAX_SECONDARY_EMAILS_REACHED: 188, + ACCOUNT_OWNS_EMAIL: 189, + // If there exists an account that was created under 24hrs and + // has not verified their email address, this error is thrown + // if another user attempts to add that email to their account + // as a secondary email. + UNVERIFIED_PRIMARY_EMAIL_NEWLY_CREATED: 141, + LOGIN_WITH_SECONDARY_EMAIL: 142, + SECONDARY_EMAIL_UNKNOWN: 143, + VERIFIED_SECONDARY_EMAIL_EXISTS: 144, + RESET_PASSWORD_WITH_SECONDARY_EMAIL: 145, + INVALID_SIGNIN_CODE: 146, + CHANGE_EMAIL_TO_UNVERIFIED_EMAIL: 147, + CHANGE_EMAIL_TO_UNOWNED_EMAIL: 148, + LOGIN_WITH_INVALID_EMAIL: 149, + RESEND_EMAIL_CODE_TO_UNOWNED_EMAIL: 150, + FAILED_TO_SEND_EMAIL: 151, + INVALID_TOKEN_VERIFICATION_CODE: 152, + EXPIRED_TOKEN_VERIFICATION_CODE: 153, + TOTP_TOKEN_EXISTS: 154, + TOTP_TOKEN_NOT_FOUND: 155, + RECOVERY_CODE_NOT_FOUND: 156, + DEVICE_COMMAND_UNAVAILABLE: 157, + RECOVERY_KEY_NOT_FOUND: 158, + RECOVERY_KEY_INVALID: 159, + TOTP_REQUIRED: 160, + RECOVERY_KEY_EXISTS: 161, + UNKNOWN_CLIENT_ID: 162, + INVALID_SCOPES: 163, + STALE_AUTH_AT: 164, + REDIS_CONFLICT: 165, + NOT_PUBLIC_CLIENT: 166, + INCORRECT_REDIRECT_URI: 167, + INVALID_RESPONSE_TYPE: 168, + MISSING_PKCE_PARAMETERS: 169, + INSUFFICIENT_ACR_VALUES: 170, + INCORRECT_CLIENT_SECRET: 171, + UNKNOWN_AUTHORIZATION_CODE: 172, + MISMATCH_AUTHORIZATION_CODE: 173, + EXPIRED_AUTHORIZATION_CODE: 174, + INVALID_PKCE_CHALLENGE: 175, + UNKNOWN_SUBSCRIPTION_CUSTOMER: 176, + UNKNOWN_SUBSCRIPTION: 177, + UNKNOWN_SUBSCRIPTION_PLAN: 178, + REJECTED_SUBSCRIPTION_PAYMENT_TOKEN: 179, + SUBSCRIPTION_ALREADY_CANCELLED: 180, + REJECTED_CUSTOMER_UPDATE: 181, + REFRESH_TOKEN_UNKNOWN: 182, + INVALID_EXPIRED_OTP_CODE: 183, + SUBSCRIPTION_ALREADY_CHANGED: 184, + INVALID_PLAN_UPDATE: 185, + PAYMENT_FAILED: 186, + SUBSCRIPTION_ALREADY_EXISTS: 187, + UNKNOWN_SUBSCRIPTION_FOR_SOURCE: 188, + BILLING_AGREEMENT_EXISTS: 192, + MISSING_PAYPAL_PAYMENT_TOKEN: 193, + MISSING_PAYPAL_BILLING_AGREEMENT: 194, + UNVERIFIED_PRIMARY_EMAIL_HAS_ACTIVE_SUBSCRIPTION: 195, + IAP_INVALID_TOKEN: 196, + IAP_INTERNAL_OTHER: 197, + IAP_UNKNOWN_APPNAME: 198, + INVALID_PROMOTION_CODE: 199, + SERVER_BUSY: 201, + FEATURE_NOT_ENABLED: 202, + BACKEND_SERVICE_FAILURE: 203, + DISABLED_CLIENT_ID: 204, + THIRD_PARTY_ACCOUNT_ERROR: 205, + CANNOT_CREATE_PASSWORD: 206, + ACCOUNT_CREATION_REJECTED: 207, + IAP_PURCHASE_ALREADY_REGISTERED: 208, + INVALID_INVOICE_PREVIEW_REQUEST: 209, + UNABLE_TO_LOGIN_NO_PASSWORD_SET: 210, + INVALID_CURRENCY: 211, + SUBSCRIPTION_PROMO_CODE_NOT_APPLIED: 212, + UNSUPPORTED_LOCATION: 213, + RECOVERY_PHONE_NUMBER_ALREADY_EXISTS: 214, + RECOVERY_PHONE_NUMBER_DOES_NOT_EXIST: 215, + SMS_SEND_RATE_LIMIT_EXCEEDED: 216, + INVALID_CLOUDTASK_EMAILTYPE: 217, + RECOVERY_PHONE_REMOVE_MISSING_RECOVERY_CODES: 218, + RECOVERY_PHONE_REGISTRATION_LIMIT_REACHED: 219, + TOTP_SECRET_DOES_NOT_EXIST: 220, + RECOVERY_CODES_ALREADY_EXISTS: 221, + INTERNAL_VALIDATION_ERROR: 998, + UNEXPECTED_ERROR: 999, +}; + +/** + * Takes an object and swaps keys with values. Useful when a value -> key look up is needed. + * @param obj - Object to swap keys and values on + * @returns An object with keys and values reveresed. + */ +function swapObjectKeysAndValues(obj: { + [key: string]: string | number; +}) { + const result: { [key: string | number]: string } = {}; + for (const key in obj) { + result[obj[key]] = key; + } + return result; +} +/** + * A reversed map of errnos. + */ +export const ERRNO_REVERSE_MAP = swapObjectKeysAndValues(ERRNO); + +/** + * The Default Unexpected Error State + */ +export const DEFAULT_ERRROR = { + code: 500, + error: 'Internal Server Error', + errno: ERRNO.UNEXPECTED_ERROR, + message: 'Unspecified error', + info: 'https://mozilla.github.io/ecosystem-platform/api#section/Response-format', +}; + +/** + * List of errors that should not be sent to Sentry + */ +export const IGNORED_ERROR_NUMBERS = [ + ERRNO.BOUNCE_HARD, + ERRNO.BOUNCE_SOFT, + ERRNO.BOUNCE_COMPLAINT, +]; + +/** + * Regex to determine if message is a payload too large message. + */ +export const TOO_LARGE = /^Payload (?:content length|size) greater than maximum allowed/; + +/** + * Set of errors indicating a bad request signature + */ +export const BAD_SIGNATURE_ERRORS = [ + 'Bad mac', + 'Unknown algorithm', + 'Missing required payload hash', + 'Payload is invalid', +]; + +/** + * Payload properties that might help us debug unexpected errors + * when they show up in production. Obviously we don't want to + * accidentally send any sensitive data or PII to a 3rd-party, + * so the set is opt-in rather than opt-out. + */ +export const DEBUGGABLE_PAYLOAD_KEYS = new Set([ + 'availableCommands', + 'capabilities', + 'client_id', + 'code', + 'command', + 'duration', + 'excluded', + 'features', + 'messageId', + 'metricsContext', + 'name', + 'preVerified', + 'publicKey', + 'reason', + 'redirectTo', + 'reminder', + 'scope', + 'service', + 'target', + 'to', + 'TTL', + 'ttl', + 'type', + 'unblockCode', + 'verificationMethod', +]); diff --git a/libs/accounts/errors/src/index.spec.ts b/libs/accounts/errors/src/index.spec.ts new file mode 100644 index 00000000000..caabd62a0c8 --- /dev/null +++ b/libs/accounts/errors/src/index.spec.ts @@ -0,0 +1,337 @@ +/* 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 { fullStack } from 'verror'; +import { AppError, Request } from './app-error'; +import { OauthError } from './oauth-error'; +import { RequestRoute } from 'hapi'; + +describe('AppErrors', () => { + const mockApp = { + acceptLanguage: 'en, fr', + locale: 'en', + geo: { + city: 'Mountain View', + state: 'California', + }, + ua: { + os: 'Android', + osVersion: '9', + }, + devices: Promise.resolve([{ id: 1 }]), + metricsContext: Promise.resolve({ + service: 'sync', + }), + }; + + const mockRequest = { + app: mockApp, + route: { path: '/foo' }, + }; + + // In a server environment, this should be configured or dynamically + // determined from paths registered by the server! + const mockOAuthRoutes = [ + { + path: '/token', + config: { + cors: true, + }, + }, + ]; + + it('converts an OauthError into AppError when not an oauth route', function () { + const oauthError = OauthError.invalidAssertion(); + expect(oauthError.errno).toEqual(104); + + const result = AppError.translate( + { + app: mockApp, + route: { path: '/v1/oauth/token' }, + }, + oauthError, + mockOAuthRoutes + ); + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(110); + }); + + it('keeps an OauthError with an oauth route', () => { + const oauthError = OauthError.invalidAssertion(); + expect(oauthError.errno).toEqual(104); + + const result = AppError.translate( + { app: mockApp, route: { path: '/v1/token' } }, + oauthError, + mockOAuthRoutes + ); + + expect(result).toBeInstanceOf(OauthError); + expect(result.errno).toEqual(104); + }); + + it('should translate with missing required parameters', () => { + const result = AppError.translate( + mockRequest, + { + output: { + payload: { + message: `foo${'is required'}`, + validation: { + keys: ['bar', 'baz'], + }, + }, + }, + }, + mockOAuthRoutes + ); + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(108); + expect(result.message).toEqual('Missing parameter in request body: bar'); + expect(result.output.statusCode).toEqual(400); + expect(result.output.payload.error).toEqual('Bad Request'); + expect(result.output.payload.errno).toEqual(result.errno); + expect(result.output.payload.message).toEqual(result.message); + expect(result.output.payload.param).toEqual('bar'); + }); + + it('should translate with payload data', () => { + const data = { + TIMESTAMP: '2021-01-16T01:43:33Z', + CORRELATIONID: '950b61c42c10d', + ACK: 'Failure', + VERSION: '204', + BUILD: '55251101', + L_ERRORCODE0: '11451', + L_SHORTMESSAGE0: 'Billing Agreement Id or transaction Id is not valid', + L_LONGMESSAGE0: 'Billing Agreement Id or transaction Id is not valid', + L_SEVERITYCODE0: 'Error', + }; + + const result = AppError.translate( + mockRequest, + { + output: { + statusCode: 500, + payload: { + error: 'Internal Server Error', + }, + }, + data: data, + }, + mockOAuthRoutes + ); + + expect(JSON.stringify(data)).toEqual(result.output.payload.data); + }); + + it('should translate with invalid parameter', () => { + const result = AppError.translate( + mockRequest, + { + output: { + payload: { + validation: 'foo', + }, + }, + }, + mockOAuthRoutes + ); + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(107); + expect(result.message).toEqual('Invalid parameter in request body'); + expect(result.output.statusCode).toEqual(400); + expect(result.output.payload.error).toEqual('Bad Request'); + expect(result.output.payload.errno).toEqual(result.errno); + expect(result.output.payload.message).toEqual(result.message); + expect(result.output.payload.validation).toEqual('foo'); + }); + + it('should translate with missing payload', () => { + const result = AppError.translate( + mockRequest, + { + output: {}, + }, + mockOAuthRoutes + ); + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(999); + expect(result.message).toEqual('Unspecified error'); + expect(result.output.statusCode).toEqual(500); + expect(result.output.payload.error).toEqual('Internal Server Error'); + expect(result.output.payload.errno).toEqual(result.errno); + expect(result.output.payload.message).toEqual(result.message); + }); + + it('maps an errno to its key', () => { + const error = AppError.cannotLoginNoPasswordSet(); + const actual = AppError.mapErrnoToKey(error); + expect(actual).toEqual('UNABLE_TO_LOGIN_NO_PASSWORD_SET'); + }); + + it('backend error includes a cause error when supplied', () => { + const originalError = new Error('Service timed out.'); + const err = AppError.backendServiceFailure( + 'test', + 'checking', + {}, + originalError + ); + const fullError = fullStack(err); + expect(fullError).toContain('caused by:'); + expect(fullError).toContain('Error: Service timed out.'); + }); + + it('tooManyRequests', () => { + let result = AppError.tooManyRequests(900, 'in 15 minutes'); + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(114); + expect(result.message).toEqual('Client has sent too many requests'); + expect(result.output.statusCode).toEqual(429); + expect(result.output.payload.error).toEqual('Too Many Requests'); + expect(result.output.payload.retryAfter).toEqual(900); + expect(result.output.payload.retryAfterLocalized).toEqual('in 15 minutes'); + + result = AppError.tooManyRequests(900); + expect(result.output.payload.retryAfter).toEqual(900); + expect(!result.output.payload.retryAfterLocalized).toBeTruthy(); + }); + + it('iapInvalidToken', () => { + const defaultErrorMessage = 'Invalid IAP token'; + let result = AppError.iapInvalidToken(); + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(196); + expect(result.message).toEqual(defaultErrorMessage); + expect(result.output.statusCode).toEqual(400); + expect(result.output.payload.error).toEqual('Bad Request'); + + result = AppError.iapInvalidToken({ + name: 'Invalid IAP Token', + message: '', + }); + expect(result.message).toEqual(defaultErrorMessage); + + result = AppError.iapInvalidToken({ + name: 'Invalid IAP Token', + message: 'Wow helpful extra info', + }); + expect(result.message).toEqual( + `${defaultErrorMessage}: Wow helpful extra info` + ); + }); + + it('unexpectedError without request data', () => { + const err = AppError.unexpectedError(); + expect(err).toBeInstanceOf(AppError); + expect(err).toBeInstanceOf(Error); + expect(err.errno).toEqual(999); + expect(err.message).toEqual('Unspecified error'); + expect(err.output.statusCode).toEqual(500); + expect(err.output.payload.error).toEqual('Internal Server Error'); + expect(err.output.payload.request).toBeUndefined(); + }); + + it('unexpectedError with request data', () => { + const err = AppError.unexpectedError({ + app: { + acceptLanguage: 'en, fr', + locale: 'en', + geo: { + city: 'Mountain View', + state: 'California', + }, + ua: { + os: 'Android', + osVersion: '9', + }, + devices: Promise.resolve([{ id: 1 }]), + metricsContext: Promise.resolve({ + service: 'sync', + }), + }, + method: 'GET', + path: '/v1/wibble', + route: { + path: '/v1/wibble', + } as unknown as RequestRoute, + query: { + foo: 'bar', + }, + payload: { + baz: 'qux', + email: 'foo@example.com', + displayName: 'Foo Bar', + metricsContext: { + utmSource: 'thingy', + }, + service: 'sync', + }, + headers: { + // x-forwarded-for is stripped out because it contains internal server IPs + // See https://github.com/mozilla/fxa-private/issues/66 + 'x-forwarded-for': '192.168.1.1 192.168.2.2', + wibble: 'blee', + }, + } as unknown as Request); + expect(err.errno).toEqual(999); + expect(err.message).toEqual('Unspecified error'); + expect(err.output.statusCode).toEqual(500); + expect(err.output.payload.error).toEqual('Internal Server Error'); + expect(err.output.payload.request).toEqual({ + acceptLanguage: 'en, fr', + locale: 'en', + userAgent: { + os: 'Android', + osVersion: '9', + }, + method: 'GET', + path: '/v1/wibble', + query: { + foo: 'bar', + }, + payload: { + metricsContext: { + utmSource: 'thingy', + }, + service: 'sync', + }, + headers: { + wibble: 'blee', + }, + }); + }); + + const reasons = ['socket hang up', 'ECONNREFUSED']; + reasons.forEach((reason) => { + it(`converts ${reason} errors to backend service error`, () => { + const result = AppError.translate( + mockRequest, + { + output: { + payload: { + errno: 999, + statusCode: 500, + }, + }, + reason, + }, + mockOAuthRoutes + ); + + expect(result).toBeInstanceOf(AppError); + expect(result.errno).toEqual(203); + expect(result.message).toEqual('System unavailable, try again soon'); + expect(result.output.statusCode).toEqual(500); + expect(result.output.payload.error).toEqual('Internal Server Error'); + expect(result.output.payload.errno).toEqual( + AppError.ERRNO.BACKEND_SERVICE_FAILURE + ); + expect(result.output.payload.message).toEqual( + 'System unavailable, try again soon' + ); + }); + }); +}); diff --git a/libs/accounts/errors/src/index.ts b/libs/accounts/errors/src/index.ts new file mode 100644 index 00000000000..aaa04cf8d9f --- /dev/null +++ b/libs/accounts/errors/src/index.ts @@ -0,0 +1,6 @@ +/* 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/. */ + +export * from './app-error'; +export * from './oauth-error'; diff --git a/libs/accounts/errors/src/oauth-error.ts b/libs/accounts/errors/src/oauth-error.ts new file mode 100644 index 00000000000..a970d7ce978 --- /dev/null +++ b/libs/accounts/errors/src/oauth-error.ts @@ -0,0 +1,439 @@ +/* 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 { DEFAULT_ERRROR } from './constants'; + +// Leaving this here for historical reasons. But we should probably get rid +// of references to this legacy buff package... See FXA-12594 +const hex = require('buf').to.hex; + +// Deprecated. New error types should be defined in lib/error.js +export class OauthError extends Error { + isBoom: boolean; + errno: number; + output: { + statusCode: number; + payload: { + code: number; + errno: number; + error: any; + message: string; + info: Record; + log?:unknown; + }; + headers: Record; + }; + + constructor(options: any, extra?: any, headers?: any) { + super(options.message || DEFAULT_ERRROR.message); + this.message = options.message || DEFAULT_ERRROR.message; + this.isBoom = true; + if (options.stack) { + this.stack = options.stack; + } else { + Error.captureStackTrace(this, OauthError); + } + this.errno = options.errno || DEFAULT_ERRROR.errno; + this.output = { + statusCode: options.code || DEFAULT_ERRROR.code, + payload: { + code: options.code || DEFAULT_ERRROR.code, + errno: this.errno, + error: options.error || DEFAULT_ERRROR.error, + message: this.message, + info: options.info || DEFAULT_ERRROR.info, + }, + headers: headers || {}, + }; + merge(this.output.payload, extra); + } + + override toString() { + return 'Error: ' + this.message; + } + + header(name:string, value:string|number) { + this.output.headers[name] = value; + } + + translate(response:{output:{payload:any}, stack:string}) { + if (response instanceof OauthError) { + return response; + } + + let error; + const payload = response.output.payload; + if (payload.validation) { + error = OauthError.invalidRequestParameter(payload.validation); + } else if (payload.statusCode === 415) { + error = OauthError.invalidContentType(); + } else { + error = new OauthError({ + message: payload.message, + code: payload.statusCode, + error: payload.error, + errno: payload.errno, + stack: response.stack, + }); + } + + return error; + } + + static isOauthRoute(path:string, routes?:Array<{path:string, config:{cors:any}}>) { + + if (!routes) { + console.log('!!! no routes') + return false; + } + + console.log('!!! ', routes, path, routes.findIndex((r) => { + console.log(`/v1${r.path}`, path) + return `/v1${r.path}` === path; + })) + + return ( + // For now we use a fragile heuristic that all oauth routes set cors config + // TODO: when we merge oauth errors into auth, rethink this. + routes.findIndex((r) => `/v1${r.path}` === path && r.config.cors) > -1 + ); + } + + static translate( + response: { + output: { + payload?:any; + }, + stack?:string + } | OauthError + ):OauthError { + + if (response instanceof OauthError) { + return response; + } + + const payload = response.output.payload; + + if (payload?.validation) { + return OauthError.invalidRequestParameter(payload.validation); + } + + if (payload?.statusCode === 415) { + return OauthError.invalidContentType(); + } + + return new OauthError({ + message: payload?.message, + code: payload?.statusCode, + error: payload?.error, + errno: payload?.errno, + stack: response.stack, + }); + } + + backtrace(traced:unknown) { + this.output.payload.log = traced; + }; + + static unexpectedError() { + return new OauthError({}); + } + + static unknownClient(clientId:string|Buffer) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 101, + message: 'Unknown client', + }, + { + clientId: hex(clientId), + } + ); + } + + static incorrectSecret(clientId:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 102, + message: 'Incorrect secret', + }, + { + clientId: hex(clientId), + } + ); + } + + static incorrectRedirect(uri:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 103, + message: 'Incorrect redirect_uri', + }, + { + redirectUri: uri, + } + ); + } + + static invalidAssertion() { + return new OauthError({ + code: 401, + error: 'Bad Request', + errno: 104, + message: 'Invalid assertion', + }); + } + + static unknownCode(code:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 105, + message: 'Unknown code', + }, + { + requestCode: code, + } + ); + } + + static mismatchCode(code:string, clientId:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 106, + message: 'Incorrect code', + }, + { + requestCode: hex(code), + client: hex(clientId), + } + ); + } + + static expiredCode(code:string, expiredAt:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 107, + message: 'Expired code', + }, + { + requestCode: hex(code), + expiredAt: expiredAt, + } + ); + } + + static invalidToken() { + return new OauthError({ + code: 400, + error: 'Bad Request', + errno: 108, + message: 'Invalid token', + }); + } + + static invalidRequestParameter(val:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 109, + message: 'Invalid request parameter', + }, + { + validation: val, + } + ); + } + + static invalidResponseType() { + return new OauthError({ + code: 400, + error: 'Bad Request', + errno: 110, + message: 'Invalid response_type', + }); + } + + static unauthorized(reason:string) { + return new OauthError( + { + code: 401, + error: 'Unauthorized', + errno: 111, + message: 'Unauthorized for route', + }, + { + detail: reason, + } + ); + } + + static forbidden() { + return new OauthError({ + code: 403, + error: 'Forbidden', + errno: 112, + message: 'Forbidden', + }); + } + + static invalidContentType() { + return new OauthError({ + code: 415, + error: 'Unsupported Media Type', + errno: 113, + message: + 'Content-Type must be either application/json or ' + + 'application/x-www-form-urlencoded', + }); + } + + static invalidScopes(scopes:string) { + return new OauthError( + { + code: 400, + error: 'Invalid scopes', + errno: 114, + message: 'Requested scopes are not allowed', + }, + { + invalidScopes: scopes, + } + ); + } + + static expiredToken(expiredAt:number) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 115, + message: 'Expired token', + }, + { + expiredAt: expiredAt, + } + ); + } + + static notPublicClient(clientId:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 116, + message: 'Not a public client', + }, + { + clientId: hex(clientId), + } + ); + } + + static mismatchCodeChallenge(pkceHashValue:string) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 117, + message: 'Incorrect code_challenge', + }, + { + requestCodeChallenge: pkceHashValue, + } + ); + } + + static missingPkceParameters() { + return new OauthError({ + code: 400, + error: 'PKCE parameters missing', + errno: 118, + message: 'Public clients require PKCE OAuth parameters', + }); + } + + static staleAuthAt(authAt:number) { + return new OauthError( + { + code: 401, + error: 'Bad Request', + errno: 119, + message: 'Stale authentication timestamp', + }, + { + authAt: authAt, + } + ); + } + + static mismatchAcr(foundValue:boolean) { + return new OauthError( + { + code: 400, + error: 'Bad Request', + errno: 120, + message: 'Mismatch acr value', + }, + { foundValue } + ); + } + + static invalidGrantType() { + return new OauthError({ + code: 400, + error: 'Bad Request', + errno: 121, + message: 'Invalid grant_type', + }); + } + + static unknownToken() { + return new OauthError({ + code: 400, + error: 'Bad Request', + errno: 122, + message: 'Unknown token', + }); + } + + // N.B. `errno: 201` is traditionally our generic "service unavailable" error, + // so let's reserve it for that purpose here as well. + + static disabledClient(clientId:string) { + return new OauthError( + { + code: 503, + error: 'Client Disabled', + errno: 202, // TODO reconcile this with the auth-server version + message: 'This client has been temporarily disabled', + }, + { clientId } + ); + } +} + +/** + * Utility function to merge one object into an other. + * @param target The main object + * @param other The object being merged into the main object. + */ +function merge(target: any, other: any) { + const keys = Object.keys(other || {}); + for (let i = 0; i < keys.length; i++) { + target[keys[i]] = other[keys[i]]; + } +} diff --git a/libs/accounts/errors/src/util.ts b/libs/accounts/errors/src/util.ts new file mode 100644 index 00000000000..6bca9209437 --- /dev/null +++ b/libs/accounts/errors/src/util.ts @@ -0,0 +1,18 @@ +export function swapObjectKeysAndValues(obj: { + [key: string]: string | number; +}) { + const result: { [key: string | number]: string } = {}; + for (const key in obj) { + result[obj[key]] = key; + } + return result; +} + +// Parse a comma-separated list with allowance for varied whitespace +export function commaSeparatedListToArray(s: string) { + return (s || '') + .trim() + .split(',') + .map((c) => c.trim()) + .filter((c) => !!c); +} diff --git a/libs/accounts/errors/tsconfig.json b/libs/accounts/errors/tsconfig.json new file mode 100644 index 00000000000..86622aca55a --- /dev/null +++ b/libs/accounts/errors/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "importHelpers": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/accounts/errors/tsconfig.lib.json b/libs/accounts/errors/tsconfig.lib.json new file mode 100644 index 00000000000..4befa7f0990 --- /dev/null +++ b/libs/accounts/errors/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/accounts/errors/tsconfig.spec.json b/libs/accounts/errors/tsconfig.spec.json new file mode 100644 index 00000000000..69a251f328c --- /dev/null +++ b/libs/accounts/errors/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package.json b/package.json index 4416455290e..604d8a7a415 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "@types/babel__preset-env": "^7", "@types/bn.js": "^5", "@types/classnames": "2.3.1", + "@types/hapi": "^18.0.15", "@types/jest": "29.5.14", "@types/jsdom": "^21", "@types/module-alias": "^2", diff --git a/tsconfig.base.json b/tsconfig.base.json index e60f3290439..afaef6f83ee 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -24,6 +24,7 @@ "useUnknownInCatchVariables": false, "baseUrl": ".", "paths": { + "@fxa/accounts/errors": ["libs/accounts/errors/src/index.ts"], "@fxa/accounts/rate-limit": ["libs/accounts/rate-limit/src/index.ts"], "@fxa/accounts/recovery-phone": [ "libs/accounts/recovery-phone/src/index.ts" diff --git a/yarn.lock b/yarn.lock index 8e0bffe698a..27a5b5d30ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20881,6 +20881,13 @@ __metadata: languageName: node linkType: hard +"@types/boom@npm:*": + version: 7.3.5 + resolution: "@types/boom@npm:7.3.5" + checksum: 10c0/6b8577f26b22950b4d0fd1770b30f0d959c6d15275a72a381b91aea3772313675c6293fac6cd484cb40eec7715f1d321cacaacd7b510a68c3d21932af5c5f837 + languageName: node + linkType: hard + "@types/bunyan@npm:1.8.11": version: 1.8.11 resolution: "@types/bunyan@npm:1.8.11" @@ -20918,6 +20925,13 @@ __metadata: languageName: node linkType: hard +"@types/catbox@npm:*": + version: 10.0.9 + resolution: "@types/catbox@npm:10.0.9" + checksum: 10c0/ec86aa00b64cf8912152cc937fc06604fecf602f37529aa1ae5c2991be9c957376bcb3df816c244bdbcd66f8822e2b36ac1b46a7ebc03e7d3d4c8e721271021e + languageName: node + linkType: hard + "@types/chai-as-promised@npm:^7": version: 7.1.8 resolution: "@types/chai-as-promised@npm:7.1.8" @@ -21327,6 +21341,22 @@ __metadata: languageName: node linkType: hard +"@types/hapi@npm:^18.0.15": + version: 18.0.15 + resolution: "@types/hapi@npm:18.0.15" + dependencies: + "@types/boom": "npm:*" + "@types/catbox": "npm:*" + "@types/iron": "npm:*" + "@types/mimos": "npm:*" + "@types/node": "npm:*" + "@types/podium": "npm:*" + "@types/shot": "npm:*" + joi: "npm:^17.3.0" + checksum: 10c0/c17e6c1fbe1601c49f6d1616ffbe1ccc1275716c6a0ab98be780a51c11e6c2c3f8ab8edb0af184176434fdf49d9e2a1e2c86b50c3322fe7d16fd8e47762157c6 + languageName: node + linkType: hard + "@types/hapi__catbox@npm:*": version: 10.2.6 resolution: "@types/hapi__catbox@npm:10.2.6" @@ -21467,6 +21497,15 @@ __metadata: languageName: node linkType: hard +"@types/iron@npm:*": + version: 5.0.5 + resolution: "@types/iron@npm:5.0.5" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/02300572301894077e19a63f069a8b8c41e185e4fe0115c33ffd18f304e3bc5cee58c9c901ff59f1782282f2feb7bae3139afd2024754e4f1ca7e60c4a47e11a + languageName: node + linkType: hard + "@types/isomorphic-fetch@npm:0.0.34": version: 0.0.34 resolution: "@types/isomorphic-fetch@npm:0.0.34" @@ -21803,6 +21842,15 @@ __metadata: languageName: node linkType: hard +"@types/mimos@npm:*": + version: 3.0.6 + resolution: "@types/mimos@npm:3.0.6" + dependencies: + "@types/mime-db": "npm:*" + checksum: 10c0/5a8693e27e0abce0641ec07b38b27f126e11c6ab1e9ea7ad52ff60065617ad1ac5e8209b5041d9184a9ab713aad022c4bee06aebfb6c33a9dfa1a18d1d35029a + languageName: node + linkType: hard + "@types/minimatch@npm:^3.0.3": version: 3.0.5 resolution: "@types/minimatch@npm:3.0.5" @@ -22071,6 +22119,13 @@ __metadata: languageName: node linkType: hard +"@types/podium@npm:*": + version: 1.0.4 + resolution: "@types/podium@npm:1.0.4" + checksum: 10c0/d7a602f306b31eb5b62c8bdcc0d9f31034fbbf0b5f00bac552f7702128c121b4e21d291b19de2042683642d5af83243c8c6642e8846e6ef3c137594cc20b670b + languageName: node + linkType: hard + "@types/postcss-import@npm:^12": version: 12.0.1 resolution: "@types/postcss-import@npm:12.0.1" @@ -22459,6 +22514,15 @@ __metadata: languageName: node linkType: hard +"@types/shot@npm:*": + version: 4.0.5 + resolution: "@types/shot@npm:4.0.5" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/c01275b11a6d2694b1c3ee3b31cbfe7b8a48b35a73253185cea03e0ad6fc949a2c348ff40688601d39b8d837581312b96200df9f844bb1f6f786ce4710aa36b7 + languageName: node + linkType: hard + "@types/sinon-chai@npm:3.2.5": version: 3.2.5 resolution: "@types/sinon-chai@npm:3.2.5" @@ -35281,6 +35345,7 @@ __metadata: "@types/babel__preset-env": "npm:^7" "@types/bn.js": "npm:^5" "@types/classnames": "npm:2.3.1" + "@types/hapi": "npm:^18.0.15" "@types/jest": "npm:29.5.14" "@types/jsdom": "npm:^21" "@types/module-alias": "npm:^2"