packages/fxa-auth-server/lib/routes/linked-accounts.ts (571 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/. */ import { AuthLogger, AuthRequest } from '../types'; import { ConfigType } from '../../config'; import { OAuth2Client } from 'google-auth-library'; import axios from 'axios'; import * as uuid from 'uuid'; import * as random from '../crypto/random'; import * as jose from 'jose'; import validators from './validators'; import { Provider, PROVIDER, PROVIDER_NAME, } from 'fxa-shared/db/models/auth/linked-account'; import THIRD_PARTY_AUTH_DOCS from '../../docs/swagger/third-party-auth-api'; import isA from 'joi'; import DESCRIPTION from '../../docs/swagger/shared/descriptions'; import error from '../error'; import { schema as METRICS_CONTEXT_SCHEMA } from '../metrics/context'; import { getGooglePublicKey, getApplePublicKey, isValidClientId, validateSecurityToken, googleEventHandlers, handleGoogleOtherEventType, GoogleJWTSETPayload, AppleJWTSETPayload, appleEventHandlers, AppleSETEvent, } from './utils/third-party-events'; import { gleanMetrics } from '../metrics/glean'; import { VError } from 'verror'; import { ProfileClient, ProfileClientError, ProfileClientServiceFailureError, } from '@fxa/profile/client'; import { StatsD } from 'hot-shots'; const HEX_STRING = validators.HEX_STRING; const APPLE_AUD = 'https://appleid.apple.com'; export class LinkedAccountHandler { private googleAuthClient?: OAuth2Client; private otpUtils: any; private goooglePublicKey: any; private applePublicKey: any; constructor( private log: AuthLogger, private db: any, private config: ConfigType, private mailer: any, private profile: ProfileClient, private statsd: StatsD, private glean: ReturnType<typeof gleanMetrics> ) { if (config.googleAuthConfig && config.googleAuthConfig.clientId) { this.googleAuthClient = new OAuth2Client( config.googleAuthConfig.clientId ); } this.otpUtils = require('./utils/otp')(log, config, db, statsd); } // As generated tokens expire after 6 months (180 days) per Apple documentation, // generate JWT for client secret on each request instead async generateAppleClientSecret( clientId: string, keyId: string, privateKey: string, teamId: string ) { const ecPrivateKey = await jose.importPKCS8(privateKey, 'ES256'); const jwt = await new jose.SignJWT({}) .setProtectedHeader({ alg: 'ES256', kid: keyId }) .setIssuedAt() .setIssuer(teamId) .setAudience(APPLE_AUD) .setExpirationTime('1m') .setSubject(clientId) .sign(ecPrivateKey); return jwt; } async handleAppleSET(request: AuthRequest) { this.statsd.increment('handleAppleSET.received'); // Apple does not set the JWT header, instead they pass it as // in a payload object. const { payload: token } = request.payload as any; if (!this.applePublicKey) { this.applePublicKey = await getApplePublicKey(token); } try { const jwtPayload = (await validateSecurityToken( token, this.config.appleAuthConfig.securityEventsClientIds, this.applePublicKey.pem, APPLE_AUD )) as AppleJWTSETPayload; const { events } = jwtPayload; const parsedEventData: AppleSETEvent = JSON.parse(events); this.statsd.increment('handleAppleSET.decoded'); const eventType = parsedEventData.type; if (appleEventHandlers[eventType as keyof typeof appleEventHandlers]) { this.statsd.increment(`handleAppleSET.processing.${eventType}`); this.log.debug('handleAppleSET.processing', { eventType, }); await appleEventHandlers[eventType as keyof typeof appleEventHandlers]( parsedEventData, this.log, this.db ); this.statsd.increment(`handleAppleSET.processed.${eventType}`); this.log.debug(`handleAppleSET.processed`, { eventType, }); } else { this.statsd.increment(`handleAppleSET.unknownEventType.${eventType}`); } } catch (err) { this.statsd.increment('handleAppleSET.validationError'); throw err; } return {}; } async handleGoogleSET(request: AuthRequest) { this.statsd.increment('handleGoogleSET.received'); const tokenBuffer = request.payload as ArrayBuffer; const token = tokenBuffer.toString(); if (!this.goooglePublicKey) { this.goooglePublicKey = await getGooglePublicKey(token); } try { // We should ignore events from other clients. if (!isValidClientId(token, this.config.googleAuthConfig.clientId)) { this.statsd.increment('handleGoogleSET.mismatchClientId'); this.log.debug('handleGoogleSET.mismatchClientId', { clientId: this.config.googleAuthConfig.clientId, }); return {}; } const jwtPayload = (await validateSecurityToken( token, this.config.googleAuthConfig.securityEventsClientIds, this.goooglePublicKey.pem, this.goooglePublicKey.issuer )) as GoogleJWTSETPayload; this.statsd.increment('handleGoogleSET.decoded'); // Process each event type for (const eventType in jwtPayload.events) { this.statsd.increment(`handleGoogleSET.processing.${eventType}`); this.log.debug('handleGoogleSET.processing', { eventType, }); if ( googleEventHandlers[eventType as keyof typeof googleEventHandlers] ) { await googleEventHandlers[ eventType as keyof typeof googleEventHandlers ](jwtPayload.events[eventType], this.log, this.db); this.statsd.increment(`handleGoogleSET.processed.${eventType}`); this.log.debug('handleGoogleSET.processed', { eventType, }); } else { // Log that an unknown event type was received and ignore it handleGoogleOtherEventType(eventType, this.log); this.statsd.increment( `handleGoogleSET.unknownEventType.${eventType}` ); } } } catch (err) { this.statsd.increment('handleGoogleSET.validationError'); this.log.debug('handleGoogleSET.validationError', { err, }); throw err; } return {}; } async loginOrCreateAccount(request: AuthRequest) { const requestPayload = request.payload as any; const provider = requestPayload.provider as Provider; const providerId = PROVIDER[provider]; const service = requestPayload.service; // Currently, FxA supports creating a linked account via the oauth authorization flow // This flow returns an `id_token` which is used create/get FxA account. let idToken: any; const code = requestPayload.code; const { deviceId, flowId, flowBeginTime } = await request.app .metricsContext; switch (provider) { case 'google': { if (!this.googleAuthClient) { throw error.thirdPartyAccountError(); } const { clientId, clientSecret, redirectUri } = this.config.googleAuthConfig; let rawIdToken; if (code) { const data = { code, client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri, grant_type: 'authorization_code', }; try { const res = await axios.post( this.config.googleAuthConfig.tokenEndpoint, data ); // We currently only use the `id_token` after completing the // authorization code exchange. In the future we could store a // refresh token to do other things like revoking sessions. // // See https://developers.google.com/identity/protocols/oauth2/openid-connect#exchangecode rawIdToken = res.data['id_token']; const verifiedToken = await this.googleAuthClient.verifyIdToken({ idToken: rawIdToken, audience: clientId, }); idToken = verifiedToken.getPayload(); } catch (err) { this.log.error('linked_account.code_exchange_error', err); throw error.thirdPartyAccountError(); } } break; } case 'apple': { const { clientId, keyId, privateKey, teamId } = this.config.appleAuthConfig; if (!clientId || !keyId || !privateKey || !teamId) { throw error.thirdPartyAccountError(); } let rawIdToken; const clientSecret = await this.generateAppleClientSecret( clientId, keyId, privateKey, teamId ); const code = requestPayload.code; if (code) { const data = { code, client_id: clientId, client_secret: clientSecret, grant_type: 'authorization_code', }; try { const res = await axios.post( this.config.appleAuthConfig.tokenEndpoint, new URLSearchParams(data).toString() ); rawIdToken = res.data['id_token']; idToken = jose.decodeJwt(rawIdToken); } catch (err) { this.log.error('linked_account.code_exchange_error', err); throw error.thirdPartyAccountError(); } } break; } } if (!idToken) { throw error.thirdPartyAccountError(); } const userid = idToken.sub; const email = idToken.email; const name = idToken.name; let accountRecord; const linkedAccountRecord = await this.db.getLinkedAccount( userid, provider ); if (!linkedAccountRecord) { // Something has gone wrong! We shouldn't hit a case where we have an unlinked without // an email set in the idToken. Failing hard and fast. Logging more info if (!email) { this.log.error('linked_account.no_email_in_id_token', { provider, userid, name, }); throw error.thirdPartyAccountError(); } try { // This is a new third party account linking an existing FxA account accountRecord = await this.db.accountRecord(email); await this.db.createLinkedAccount(accountRecord.uid, userid, provider); if (name) { await this.updateProfileDisplayName(accountRecord.uid, name); } const geoData = request.app.geo; const ip = request.app.clientAddress; const emailOptions = { acceptLanguage: request.app.acceptLanguage, deviceId, flowId, flowBeginTime, ip, location: geoData.location, providerName: PROVIDER_NAME[provider], timeZone: geoData.timeZone, uaBrowser: request.app.ua.browser, uaBrowserVersion: request.app.ua.browserVersion, uaOS: request.app.ua.os, uaOSVersion: request.app.ua.osVersion, uaDeviceType: request.app.ua.deviceType, uid: accountRecord.uid, }; await this.mailer.sendPostAddLinkedAccountEmail( accountRecord.emails, accountRecord, emailOptions ); request.setMetricsFlowCompleteSignal('account.login', 'login'); switch (provider) { case 'google': await this.glean.thirdPartyAuth.googleLoginComplete(request, { reason: 'linking', }); break; case 'apple': await this.glean.thirdPartyAuth.appleLoginComplete(request, { reason: 'linking', }); break; } await request.emitMetricsEvent('account.login', { uid: accountRecord.uid, deviceId, flowId, flowBeginTime, service, }); } catch (err) { this.log.trace( 'Account.login.sendPostAddLinkedAccountNotification.error', { error: err, } ); if (err.errno !== error.ERRNO.ACCOUNT_UNKNOWN) { throw err; } // This is a new user creating a new FxA account, we // create the FxA account with random password and mark email // verified const emailCode = await random.hex(16); const authSalt = await random.hex(32); const [kA, wrapWrapKb, wrapWrapKbVersion2] = await random.hex( 32, 32, 32 ); accountRecord = await this.db.createAccount({ uid: uuid.v4({}, Buffer.alloc(16)).toString('hex'), createdAt: Date.now(), email, emailCode, emailVerified: true, kA, wrapWrapKb, wrapWrapKbVersion2, authSalt, // This will be set with a real value when the users sets an account password. clientSalt: undefined, verifierVersion: this.config.verifierVersion, verifyHash: Buffer.alloc(32).toString('hex'), verifyHashVersion2: Buffer.alloc(32).toString('hex'), verifierSetAt: 0, locale: request.app.acceptLanguage, }); await this.db.createLinkedAccount(accountRecord.uid, userid, provider); if (name) { await this.updateProfileDisplayName(accountRecord.uid, name); } // Currently, we treat accounts created from a linked account as a new // registration and emit the correspond event. Note that depending on // where might not be a top of funnel for this completion event. request.setMetricsFlowCompleteSignal( 'account.verified', 'registration' ); switch (provider) { case 'google': await this.glean.thirdPartyAuth.googleRegComplete(request); break; case 'apple': await this.glean.thirdPartyAuth.appleRegComplete(request); break; } await request.emitMetricsEvent('account.verified', { uid: accountRecord.uid, deviceId, flowId, flowBeginTime, service, }); this.glean.registration.complete(request, { uid: accountRecord.uid }); } } else { // This is an existing user and existing FxA user accountRecord = await this.db.account(linkedAccountRecord.uid); if (service === 'sync') { request.setMetricsFlowCompleteSignal('account.signed', 'login'); } else { request.setMetricsFlowCompleteSignal('account.login', 'login'); } await request.emitMetricsEvent('account.login', { uid: accountRecord.uid, deviceId, flowId, flowBeginTime, service, }); switch (provider) { case 'google': await this.glean.thirdPartyAuth.googleLoginComplete(request); break; case 'apple': await this.glean.thirdPartyAuth.appleLoginComplete(request); break; } } let verificationMethod, mustVerifySession = false, tokenVerificationId = undefined; const hasTotpToken = await this.otpUtils.hasTotpToken(accountRecord); if (hasTotpToken) { mustVerifySession = true; tokenVerificationId = await random.hex(16); verificationMethod = 'totp-2fa'; } const sessionTokenOptions = { uid: accountRecord.uid, email: accountRecord.primaryEmail.email, emailCode: accountRecord.primaryEmail.emailCode, emailVerified: accountRecord.primaryEmail.isVerified, verifierSetAt: accountRecord.verifierSetAt, mustVerify: mustVerifySession, tokenVerificationId, uaBrowser: request.app.ua.browser, uaBrowserVersion: request.app.ua.browserVersion, uaOS: request.app.ua.os, uaOSVersion: request.app.ua.osVersion, uaDeviceType: request.app.ua.deviceType, uaFormFactor: request.app.ua.formFactor, providerId, }; const sessionToken = await this.db.createSessionToken(sessionTokenOptions); return { uid: sessionToken.uid, sessionToken: sessionToken.data, providerUid: userid, email, ...(verificationMethod ? { verificationMethod } : {}), }; } async unlinkAccount(request: AuthRequest) { if (!this.googleAuthClient) { throw error.thirdPartyAccountError(); } const sessionToken = request.auth && request.auth.credentials; const uid = sessionToken.uid; const hasTotpToken = await this.otpUtils.hasTotpToken({ uid }); // Ensure that the session has the correct assurance level before unlinking if ( sessionToken && hasTotpToken && (sessionToken.tokenVerificationId || (sessionToken.authenticatorAssuranceLevel as number) <= 1) ) { throw error.unverifiedSession(); } const provider = (request.payload as any).provider.toLowerCase(); // TODO: here we'll also delete any session tokens created via a google login await this.db.deleteLinkedAccount(uid, provider); return { success: true, }; } async updateProfileDisplayName(uid: string, name: string) { try { await this.profile.updateDisplayName(uid, name); } catch (profileError) { // Handle errors from ProfileClient the same way errors were handled // previously, when ProfileClient was part of the auth server. if (profileError instanceof ProfileClientServiceFailureError) { const info = VError.info(profileError); throw error.backendServiceFailure( info.serviceName, info.method.toUpperCase(), { method: info.method.toUpperCase(), path: info.path }, profileError.cause() ); } else if (profileError instanceof ProfileClientError) { throw profileError.cause(); } else { throw profileError; } } } } export const linkedAccountRoutes = ( log: AuthLogger, db: any, config: ConfigType, mailer: any, profile: ProfileClient, statsd: any, glean: ReturnType<typeof gleanMetrics> ) => { const handler = new LinkedAccountHandler( log, db, config, mailer, profile, statsd, glean ); return [ { method: 'POST', path: '/linked_account/login', options: { ...THIRD_PARTY_AUTH_DOCS.LINKED_ACCOUNT_LOGIN_POST, validate: { payload: isA.object({ idToken: validators.thirdPartyIdToken, provider: validators.thirdPartyProvider, code: validators.thirdPartyOAuthCode, metricsContext: METRICS_CONTEXT_SCHEMA, service: validators.service.optional(), }), }, response: { schema: isA.object({ uid: isA.string().regex(HEX_STRING).required(), sessionToken: isA.string().regex(HEX_STRING).required(), providerUid: isA .string() .required() .description(DESCRIPTION.providerUid), email: isA.string().required().description(DESCRIPTION.email), verificationMethod: isA .string() .optional() .description(DESCRIPTION.verificationMethod), }), }, }, handler: async (request: AuthRequest) => handler.loginOrCreateAccount(request), }, { method: 'POST', path: '/linked_account/unlink', options: { ...THIRD_PARTY_AUTH_DOCS.LINKED_ACCOUNT_UNLINK_POST, auth: { strategy: 'sessionToken', }, validate: { payload: isA.object({ provider: validators.thirdPartyProvider, }), }, response: { schema: isA.object({ success: isA.boolean().required(), }), }, }, handler: (request: AuthRequest) => handler.unlinkAccount(request), }, { method: 'POST', path: '/linked_account/webhook/google_event_receiver', options: { payload: { // Security events use the content type application/secevent+jwt, // It isn't clearly documented, but the payload is a JWT buffer. parse: 'gunzip', allow: 'application/secevent+jwt', }, }, handler: async (request: AuthRequest) => handler.handleGoogleSET(request), }, { method: 'POST', path: '/linked_account/webhook/apple_event_receiver', handler: async (request: AuthRequest) => handler.handleAppleSET(request), }, ]; }; export default { linkedAccountRoutes, LinkedAccountHandler, };