packages/fxa-auth-server/lib/server.js (450 lines of code) (raw):

/* 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/. */ 'use strict'; const fs = require('fs'); const Hapi = require('@hapi/hapi'); const HapiSwagger = require('hapi-swagger'); const path = require('path'); const { getRemoteAddressChain } = require('./getRemoteAddressChain'); const userAgent = require('fxa-shared/lib/user-agent').parseToScalars; const schemeRefreshToken = require('./routes/auth-schemes/refresh-token'); const authOauth = require('./routes/auth-schemes/auth-oauth'); const sharedSecretAuth = require('./routes/auth-schemes/shared-secret'); const pubsubAuth = require('./routes/auth-schemes/pubsub'); const googleOIDC = require('./routes/auth-schemes/google-oidc'); const { HEX_STRING } = require('./routes/validators'); const { configureSentry } = require('./sentry'); const { swaggerOptions } = require('../docs/swagger/swagger-options'); const { Account } = require('fxa-shared/db/models/auth'); const { determineLocale } = require('../../../libs/shared/l10n/src'); const { reportValidationError, } = require('fxa-shared/sentry/report-validation-error'); const { logErrorWithGlean } = require('./metrics/glean'); function trimLocale(header) { if (!header) { return header; } if (header.length < 256) { return header.trim(); } const parts = header.split(','); let str = parts[0]; if (str.length >= 255) { return null; } for (let i = 1; i < parts.length && str.length + parts[i].length < 255; i++) { str += `,${parts[i]}`; } return str.trim(); } function logValidationError(response, log) { log.error('server.ValidationError', response); reportValidationError(response.stack, response); } function logEndpointErrors(response, log) { // When requests to DB timeout and fail for unknown reason they are an 'EndpointError'. // The error response hides error information from the user, but we log it here // to better understand the DB timeouts. if (response.__proto__ && response.__proto__.name === 'EndpointError') { const endpointLog = { message: response.message, reason: response.reason, }; if (response.attempt && response.attempt.method) { // log the DB attempt to understand the action endpointLog.method = response.attempt.method; } log.error('server.EndpointError', endpointLog); } } function translateStripeErrors(response) { if (response?.type === 'StripeAuthenticationError') { response.output = { statusCode: response.statusCode, payload: { ...response.output.payload, statusCode: response.statusCode, error: response.type, message: response.message, }, }; } return response; } async function create(log, error, config, routes, db, statsd, glean) { const getGeoData = require('./geodb')(log); const metricsContext = require('./metrics/context')(log, config); const metricsEvents = require('./metrics/events')(log, config, glean); const { sharedSecret: SUBSCRIPTIONS_SECRET } = config.subscriptions; function makeCredentialFn(dbGetFn) { return function (id) { log.trace('DB.getToken', { id: id }); if (!HEX_STRING.test(id)) { return null; } return (async () => { const token = await dbGetFn(id); if (!token.expired(Date.now())) { return token; } const err = error.invalidToken('The authentication token has expired'); if (token.constructor.tokenTypeID === 'sessionToken') { return (async () => { try { await db.pruneSessionTokens(token.uid, [token]); } catch (ignoreError) { // Ignore errors } throw err; })(); } return null; })(); }; } const serverOptions = { host: config.listen.host, port: config.listen.port, routes: { cors: { additionalExposedHeaders: ['Timestamp', 'Accept-Language'], additionalHeaders: ['sentry-trace', 'baggage'], // If we're accepting CORS from any origin then use Hapi's "ignore" mode, // which is more forgiving of missing Origin header. origin: config.corsOrigin[0] === '*' ? 'ignore' : config.corsOrigin, }, security: { hsts: { maxAge: 31536000, includeSubdomains: true, }, }, state: { parse: false, }, payload: { maxBytes: 16384, }, files: { relativeTo: path.dirname(__dirname), }, validate: { options: { stripUnknown: true, }, failAction: async (request, h, err) => { // Starting with Hapi 17, the framework hides the validation info // We want the full validation information and use it in `onPreResponse` below // See: https://github.com/hapijs/hapi/issues/3706#issuecomment-349765943 throw err; }, }, response: { options: { abortEarly: false } }, }, load: { sampleInterval: 1000, maxEventLoopDelay: config.maxEventLoopDelay, }, }; if (config.useHttps) { serverOptions.tls = { key: fs.readFileSync(config.keyPath), cert: fs.readFileSync(config.certPath), }; } const server = new Hapi.Server(serverOptions); server.validator(require('joi')); server.ext('onRequest', (request, h) => { log.begin('server.onRequest', request); return h.continue; }); server.ext('onPreAuth', (request, h) => { defineLazyGetter(request.app, 'remoteAddressChain', () => { return getRemoteAddressChain(request, config.remoteAddressChainOverride); }); defineLazyGetter(request.app, 'clientAddress', () => { const remoteAddressChain = request.app.remoteAddressChain; let clientAddressIndex = remoteAddressChain.length - (config.clientAddressDepth || 1); if (clientAddressIndex < 0) { clientAddressIndex = 0; } return remoteAddressChain[clientAddressIndex]; }); defineLazyGetter(request.app, 'acceptLanguage', () => trimLocale(request.headers['accept-language']) ); defineLazyGetter(request.app, 'locale', () => determineLocale(request.app.acceptLanguage) ); defineLazyGetter(request.app, 'ua', () => userAgent(request.headers['user-agent']) ); defineLazyGetter(request.app, 'geo', () => getGeoData(request.app.clientAddress) ); defineLazyGetter(request.app, 'metricsContext', () => metricsContext.get(request) ); defineLazyGetter(request.app, 'devices', () => { let uid; if ( request.auth && request.auth.credentials && request.auth.credentials.uid ) { // sessionToken strategy comes with uid as uid uid = request.auth.credentials.uid; } else if ( request.auth && request.auth.credentials && request.auth.credentials.user ) { // oauthToken strategy comes with uid as user uid = request.auth.credentials.user; } else if (request.payload && request.payload.uid) { uid = request.payload.uid; } return db.devices(uid); }); defineLazyGetter(request.app, 'isMetricsEnabled', async () => { // This catches most but not all cases where the given uid // is opted out and saves us from making a db call further down. // Note that unverified accounts can not be opted out of metrics. let uid; if ( request.auth && request.auth.credentials && request.auth.credentials.uid ) { // sessionToken strategy sets this property already return !request.auth.credentials.metricsOptOutAt; } else if ( request.auth && request.auth.credentials && request.auth.credentials.user ) { // oauthToken strategy comes with uid as user uid = request.auth.credentials.user; } else if (request.payload && request.payload.uid) { // Some unauthenticated requests might set uid in payload, ex. `/account/status` uid = request.payload.uid; } else if (request.app.metricsEventUid) { // For access tokens, and webhooks, we stash the uid uid = request.app.metricsEventUid; } else if (request.payload && request.payload.email) { // last resort is to check if an email is in the payload try { const account = await Account.findByPrimaryEmail( request.payload.email ); uid = account.uid; } catch (err) { // Unknown accounts will have the default experience } } if (!uid) { return true; } return Account.metricsEnabled(uid); }); if (request.headers.authorization) { // Log some helpful details for debugging authentication problems. log.trace('server.onPreAuth', { rid: request.id, path: request.path, auth: request.headers.authorization, type: request.headers['content-type'] || '', }); } return h.continue; }); server.ext('onPreHandler', (request, h) => { const features = request.payload && request.payload.features; request.app.features = new Set(Array.isArray(features) ? features : []); return h.continue; }); server.ext('onPreResponse', (request, h) => { let response = request.response; if (response.isBoom) { logEndpointErrors(response, log); logErrorWithGlean({ glean, request, error: response }); // Do not log errors that either aren't a validation error or have a status code below 500 // ValidationError that are 4xx status are request validation errors if ( response?.__proto__.name === 'ValidationError' && response.output && response.output.statusCode >= 500 ) { logValidationError(response, log); } if (config.env !== 'prod') { translateStripeErrors(response); } response = error.translate(request, response); if (config.env !== 'prod') { response.backtrace(request.app.traced); } } response.header('Timestamp', `${Math.floor(Date.now() / 1000)}`); return response; }); const metricReporter = metricFactory(statsd); server.events.on('response', (request) => { log.summary(request, request.response); metricReporter(request); }); // Configure Sentry... Note, Sentry will already be initialized by this point, // but this will add a couple hooks into hapi request life cycle so that transactions // can be created. await configureSentry(server, config); server.decorate('request', 'stashMetricsContext', metricsContext.stash); server.decorate('request', 'gatherMetricsContext', metricsContext.gather); server.decorate( 'request', 'propagateMetricsContext', metricsContext.propagate ); server.decorate('request', 'clearMetricsContext', metricsContext.clear); server.decorate('request', 'validateMetricsContext', metricsContext.validate); server.decorate( 'request', 'setMetricsFlowCompleteSignal', metricsContext.setFlowCompleteSignal ); server.decorate('request', 'emitMetricsEvent', metricsEvents.emit); server.decorate( 'request', 'emitRouteFlowEvent', metricsEvents.emitRouteFlowEvent ); server.stat = function () { return { stat: 'mem', rss: server.load.rss, heapUsed: server.load.heapUsed, }; }; await server.register(require('hapi-auth-jwt2')); const hawkFxAToken = require('./routes/auth-schemes/hawk-fxa-token'); // Register auth strategies for all token types. These strategies support Hawk (without validation) and FxA token types. server.auth.scheme( 'fxa-hawk-session-token', hawkFxAToken.strategy(makeCredentialFn(db.sessionToken.bind(db))) ); server.auth.scheme( 'fxa-hawk-keyFetch-token', hawkFxAToken.strategy(makeCredentialFn(db.keyFetchToken.bind(db))) ); server.auth.scheme( 'fxa-hawk-keyFetch-with-verification-token', hawkFxAToken.strategy( makeCredentialFn(db.keyFetchTokenWithVerificationStatus.bind(db)) ) ); server.auth.scheme( 'fxa-hawk-accountReset-token', hawkFxAToken.strategy(makeCredentialFn(db.accountResetToken.bind(db))) ); server.auth.scheme( 'fxa-hawk-passwordForgot-token', hawkFxAToken.strategy(makeCredentialFn(db.passwordForgotToken.bind(db))) ); server.auth.scheme( 'fxa-hawk-passwordChange-token', hawkFxAToken.strategy(makeCredentialFn(db.passwordChangeToken.bind(db))) ); // the recoveryKey/exists route accepts a session token or a password-forgot // token. in order for Hapi to try all the strategies (until auth succeeds), // the strategy's authenticate function _must not_ throw on failed // authentication. otherwise, Hapi will stop at the first strategy that // throws. server.auth.scheme( 'multi-strategy-fxa-hawk-session-token', hawkFxAToken.strategy(makeCredentialFn(db.sessionToken.bind(db)), { throwOnFailure: false, }) ); server.auth.scheme( 'multi-strategy-fxa-hawk-passwordForgot-token', hawkFxAToken.strategy(makeCredentialFn(db.passwordForgotToken.bind(db)), { throwOnFailure: false, }) ); server.auth.strategy('sessionToken', 'fxa-hawk-session-token'); server.auth.strategy('keyFetchToken', 'fxa-hawk-keyFetch-token'); server.auth.strategy( // This strategy fetches the keyFetchToken with its // verification state. It doesn't check that state. 'keyFetchTokenWithVerificationStatus', 'fxa-hawk-keyFetch-with-verification-token' ); server.auth.strategy('accountResetToken', 'fxa-hawk-accountReset-token'); server.auth.strategy('passwordForgotToken', 'fxa-hawk-passwordForgot-token'); server.auth.strategy('passwordChangeToken', 'fxa-hawk-passwordChange-token'); server.auth.strategy( 'multiStrategySessionToken', 'multi-strategy-fxa-hawk-session-token' ); server.auth.strategy( 'multiStrategyPasswordForgotToken', 'multi-strategy-fxa-hawk-passwordForgot-token' ); server.auth.scheme(authOauth.AUTH_SCHEME, authOauth.strategy); server.auth.strategy('oauthToken', authOauth.AUTH_SCHEME, config.oauth); server.auth.scheme('fxa-oauth-refreshToken', schemeRefreshToken(config, db)); server.auth.strategy('refreshToken', 'fxa-oauth-refreshToken'); server.auth.scheme( 'subscriptionsSecret', sharedSecretAuth.strategy(SUBSCRIPTIONS_SECRET) ); server.auth.strategy('subscriptionsSecret', 'subscriptionsSecret'); server.auth.scheme( 'supportSecret', sharedSecretAuth.strategy(`Bearer ${config.support.secretBearerToken}`, { throwOnFailure: false, }) ); server.auth.strategy('supportSecret', 'supportSecret'); server.auth.strategy('pubsub', 'jwt', pubsubAuth.strategy(config)); server.auth.scheme( 'cloudTasksOIDC', googleOIDC.strategy(config.cloudTasks.oidc) ); server.auth.strategy('cloudTasksOIDC', 'cloudTasksOIDC'); server.auth.scheme( 'cloudSchedulerOIDC', googleOIDC.strategy(config.cloudScheduler.oidc) ); server.auth.strategy('cloudSchedulerOIDC', 'cloudSchedulerOIDC'); // register all plugins and Swagger configuration await server.register([ { plugin: HapiSwagger, options: swaggerOptions, }, ]); // routes should be registered after all auth strategies have initialized: // ref: http://hapijs.com/tutorials/auth server.route(routes); return server; } function defineLazyGetter(object, key, getter) { let value; Object.defineProperty(object, key, { get() { if (!value) { value = getter(); } return value; }, }); } /** * Extracted from hapi-statsd but modified to work with hotshots. * The following function contains MIT licensed code from: * https://github.com/mac-/hapi-statsd/blob/master/lib/hapi-statsd.js */ function metricFactory(statsdClient) { const pathSeparator = '_'; function normalizePath(path) { path = path.indexOf('/') === 0 ? path.substr(1) : path; return path.replace(/\//g, pathSeparator); } function reportMetrics(request) { const statusCode = request.response.isBoom ? request.response.output.statusCode : request.response.statusCode; const errno = request.response.errno || (request.response.source && request.response.source.errno) || 0; let path = request._route.path; const specials = request._core.router.specials; if (request._route === specials.notFound.route) { path = '/{notFound*}'; } else if (specials.options && request._route === specials.options.route) { path = '/{cors*}'; } else if ( request._route.path === '/' && request._route.method === 'options' ) { path = '/{cors*}'; } statsdClient.timing( 'url_request', request.info.completed - request.info.received, 1, { path: normalizePath(path), method: request.method.toUpperCase(), statusCode, errno, } ); } return reportMetrics; } module.exports = { create: create, // Functions below exported for testing _configureSentry: configureSentry, _logEndpointErrors: logEndpointErrors, _logValidationError: logValidationError, _trimLocale: trimLocale, };