diff --git a/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts b/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts index 8624e88b6e2..a36ecc7ae1c 100644 --- a/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts +++ b/packages/fxa-auth-server/lib/routes/linked-accounts.spec.ts @@ -22,21 +22,6 @@ jest.mock('google-auth-library', () => { }; }); -// Mock axios with a getter so per-test overrides work. -// Default returns the real axios so transitive deps (googleapis, google-maps) work at init time. -// eslint-disable-next-line no-var -var axiosDefaultOverride: any = null; -jest.mock('axios', () => { - const actual = jest.requireActual('axios'); - return { - ...actual, - __esModule: true, - get default() { - return axiosDefaultOverride || actual; - }, - }; -}); - jest.mock('./utils/third-party-events', () => { const actual = jest.requireActual('./utils/third-party-events'); return new Proxy(actual, { @@ -81,16 +66,9 @@ const makeRoutes = function (options: any = {}, requireMocks?: any) { if (requireMocks['google-auth-library']) { mockOAuth2ClientClass = requireMocks['google-auth-library'].OAuth2Client; } - if (requireMocks['axios']) { - axiosDefaultOverride = requireMocks['axios']; - } else { - axiosDefaultOverride = null; - } if (requireMocks['./utils/third-party-events']) { mockThirdPartyEventsModule = requireMocks['./utils/third-party-events']; } - } else { - axiosDefaultOverride = null; } // Reset FxaMailer mocks @@ -119,11 +97,19 @@ describe('/linked_account', () => { mockFxaMailer: any, mockRequest: any, route: any, - axiosMock: any, statsd: any; + let originalFetch: typeof global.fetch; const UID = 'fxauid'; + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + describe('/linked_account/login', () => { describe('google auth', () => { const mockGoogleUser = { @@ -163,12 +149,10 @@ describe('/linked_account', () => { } }; - const mockGoogleAuthResponse = { - data: { id_token: 'somedata' }, - }; - axiosMock = { - post: jest.fn(() => mockGoogleAuthResponse), - }; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id_token: 'somedata' }), + } as unknown as Response); route = getRoute( makeRoutes( @@ -183,7 +167,6 @@ describe('/linked_account', () => { 'google-auth-library': { OAuth2Client: OAuth2ClientMock, }, - axios: axiosMock, } ), '/linked_account/login' @@ -221,8 +204,11 @@ describe('/linked_account', () => { mockRequest.payload.code = 'oauth code'; const result: any = await runTest(route, mockRequest); - expect(axiosMock.post).toHaveBeenCalledTimes(1); - expect(axiosMock.post.mock.calls[0][1]).toEqual( + expect(global.fetch).toHaveBeenCalledTimes(1); + const requestBody = JSON.parse( + (global.fetch as jest.Mock).mock.calls[0][1].body + ); + expect(requestBody).toEqual( expect.objectContaining({ code: 'oauth code' }) ); @@ -243,6 +229,17 @@ describe('/linked_account', () => { expect(result.sessionToken).toBeTruthy(); }); + it('throws thirdPartyAccountError when the Google token endpoint responds non-ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 400 } as unknown as Response); + mockRequest.payload.code = 'oauth code'; + + await expect(runTest(route, mockRequest)).rejects.toMatchObject({ + errno: error.ERRNO.THIRD_PARTY_ACCOUNT_ERROR, + }); + }); + it('should create new fxa account from new google account, return session, emit Glean events', async () => { mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount(mockGoogleUser.email)) @@ -447,30 +444,23 @@ describe('/linked_account', () => { }, }); - const mockAppleAuthResponse = { - data: { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ id_token: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IkFCQzEyM0RFRkcifQ.eyJpc3MiOiJERUYxMjNHSElKIiwic3ViIjoiT29vT29vIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMiwiZXhwIjoxNTcyMzU4MDg2LCJhdWQiOiJodHRwczovL2FwcGxlaWQuYXBwbGUuY29tIiwiZW1haWwiOiJibG9vcEBtb3ppbGxhLmNvbSIsInRlYW1JZCI6Ik15IGNvb2wgdGVhbSB5byJ9.owz0xkgzDr9rLwXhd3TWV2QSRfH2YSnLt7LkS_TS42oGq_cbp1pyqhBtOBNTyvpZT6YKlxAxdmDkAr9x_KI7-A', email: 'bloop@mozilla.com', user: 'OooOoo', - }, - }; - axiosMock = { - post: jest.fn(() => mockAppleAuthResponse), - }; + }), + } as unknown as Response); route = getRoute( - makeRoutes( - { - config: mockConfig, - db: mockDB, - log: mockLog, - mailer: mockMailer, - }, - { - axios: axiosMock, - } - ), + makeRoutes({ + config: mockConfig, + db: mockDB, + log: mockLog, + mailer: mockMailer, + }), '/linked_account/login' ); glean.registration.complete.mockClear(); @@ -506,9 +496,14 @@ describe('/linked_account', () => { mockRequest.payload.code = 'oauth code'; const result: any = await runTest(route, mockRequest); - expect(axiosMock.post).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledTimes(1); + // The body is a pre-encoded form string, so the request must set this + // Content-Type explicitly — fetch won't infer it from a string body. + expect((global.fetch as jest.Mock).mock.calls[0][1].headers).toEqual({ + 'Content-Type': 'application/x-www-form-urlencoded', + }); const urlSearchParams = new URLSearchParams( - axiosMock.post.mock.calls[0][1] + (global.fetch as jest.Mock).mock.calls[0][1].body ); const params = Object.fromEntries(urlSearchParams.entries()); @@ -531,6 +526,17 @@ describe('/linked_account', () => { expect(result.sessionToken).toBeTruthy(); }); + it('throws thirdPartyAccountError when the Apple token endpoint responds non-ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 400 } as unknown as Response); + mockRequest.payload.code = 'oauth code'; + + await expect(runTest(route, mockRequest)).rejects.toMatchObject({ + errno: error.ERRNO.THIRD_PARTY_ACCOUNT_ERROR, + }); + }); + it('should create new fxa account from new apple account, return session, emit Glean events', async () => { mockDB.accountRecord = jest.fn(() => Promise.reject(error.unknownAccount(mockAppleUser.email)) diff --git a/packages/fxa-auth-server/lib/routes/linked-accounts.ts b/packages/fxa-auth-server/lib/routes/linked-accounts.ts index 0f6dc3ea6a8..83b24c1f2ef 100644 --- a/packages/fxa-auth-server/lib/routes/linked-accounts.ts +++ b/packages/fxa-auth-server/lib/routes/linked-accounts.ts @@ -4,7 +4,6 @@ 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'; @@ -279,16 +278,26 @@ export class LinkedAccountHandler { }; try { - const res = await axios.post( + const res = await fetch( this.config.googleAuthConfig.tokenEndpoint, - data + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + } ); + if (!res.ok) { + throw new Error( + `Google token endpoint responded with ${res.status}` + ); + } + const tokenResponse = await res.json(); // 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']; + rawIdToken = tokenResponse['id_token']; const verifiedToken = await this.googleAuthClient.verifyIdToken({ idToken: rawIdToken, @@ -328,11 +337,20 @@ export class LinkedAccountHandler { }; try { - const res = await axios.post( - this.config.appleAuthConfig.tokenEndpoint, - new URLSearchParams(data).toString() - ); - rawIdToken = res.data['id_token']; + const res = await fetch(this.config.appleAuthConfig.tokenEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(data).toString(), + }); + if (!res.ok) { + throw new Error( + `Apple token endpoint responded with ${res.status}` + ); + } + const tokenResponse = await res.json(); + rawIdToken = tokenResponse['id_token']; idToken = jose.decodeJwt(rawIdToken); } catch (err) { this.log.error('linked_account.code_exchange_error', err); diff --git a/packages/fxa-auth-server/lib/routes/utils/third-party-events.spec.ts b/packages/fxa-auth-server/lib/routes/utils/third-party-events.spec.ts new file mode 100644 index 00000000000..398fb91d806 --- /dev/null +++ b/packages/fxa-auth-server/lib/routes/utils/third-party-events.spec.ts @@ -0,0 +1,118 @@ +/* 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 jwt from 'jsonwebtoken'; +import { getApplePublicKey, getGooglePublicKey } from './third-party-events'; + +jest.mock('jsonwebtoken', () => ({ + decode: jest.fn(), + verify: jest.fn(), +})); + +jest.mock('@fxa/shared/pem-jwk', () => ({ + jwk2pem: jest.fn(() => 'fake-pem'), +})); + +describe('third-party-events public key fetching', () => { + let statsd: any; + let originalFetch: typeof global.fetch; + + beforeEach(() => { + statsd = { increment: jest.fn() }; + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('getApplePublicKey', () => { + it('returns the pem for the key matching the token kid', async () => { + (jwt.decode as jest.Mock).mockReturnValue({ + header: { kid: 'apple-kid' }, + }); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ keys: [{ kid: 'apple-kid', n: 'x', e: 'AQAB' }] }), + } as unknown as Response); + + const result = await getApplePublicKey('token', statsd); + + expect(result).toEqual({ pem: 'fake-pem' }); + }); + + it('throws and increments statsd when the key endpoint responds non-ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 503 } as unknown as Response); + + await expect(getApplePublicKey('token', statsd)).rejects.toThrow( + 'Failed to get Apple public key' + ); + expect(statsd.increment).toHaveBeenCalledWith('getApplePublicKey.error'); + }); + }); + + describe('getGooglePublicKey', () => { + it('returns the pem and issuer for the key matching the token kid', async () => { + (jwt.decode as jest.Mock).mockReturnValue({ + header: { kid: 'google-kid' }, + }); + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jwks_uri: 'https://example.com/jwks', + issuer: 'https://accounts.google.com', + }), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + keys: [{ kid: 'google-kid', n: 'x', e: 'AQAB' }], + }), + } as unknown as Response); + + const result = await getGooglePublicKey('token', statsd); + + expect(result).toEqual({ + pem: 'fake-pem', + issuer: 'https://accounts.google.com', + }); + }); + + it('throws and increments statsd when the RISC config endpoint responds non-ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValue({ ok: false, status: 500 } as unknown as Response); + + await expect(getGooglePublicKey('token', statsd)).rejects.toThrow( + 'Failed to get Google public key' + ); + expect(statsd.increment).toHaveBeenCalledWith('getGooglePublicKey.error'); + }); + + it('throws when the jwks endpoint responds non-ok', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jwks_uri: 'https://example.com/jwks', + issuer: 'https://accounts.google.com', + }), + } as unknown as Response) + .mockResolvedValueOnce({ + ok: false, + status: 502, + } as unknown as Response); + + await expect(getGooglePublicKey('token', statsd)).rejects.toThrow( + 'Failed to get Google public key' + ); + expect(statsd.increment).toHaveBeenCalledWith('getGooglePublicKey.error'); + }); + }); +}); diff --git a/packages/fxa-auth-server/lib/routes/utils/third-party-events.ts b/packages/fxa-auth-server/lib/routes/utils/third-party-events.ts index 646419367df..96721bac55b 100644 --- a/packages/fxa-auth-server/lib/routes/utils/third-party-events.ts +++ b/packages/fxa-auth-server/lib/routes/utils/third-party-events.ts @@ -2,7 +2,6 @@ * 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 axios from 'axios'; import { Provider, PROVIDER } from 'fxa-shared/db/models/auth/linked-account'; import jwt from 'jsonwebtoken'; import * as Sentry from '@sentry/node'; @@ -93,7 +92,7 @@ async function revokeThirdPartySessions( await db.deleteSessionToken(session); deletedCount++; } catch (deleteError) { - statsd.increment('revokeThirdPartySessions.deleteSessionToken.error'); + statsd.increment('revokeThirdPartySessions.deleteSessionToken.error'); // Continue with other sessions instead of failing completely } } @@ -237,7 +236,7 @@ async function handleGoogleSessionsRevokedEvent( await revokeThirdPartySessions(account.uid, 'google', log, db, statsd); } } catch (error) { - statsd.increment('handleGoogleSessionsRevokedEvent.error'); + statsd.increment('handleGoogleSessionsRevokedEvent.error'); // Don't rethrow - log and continue to prevent unhandled promise rejection } } @@ -284,14 +283,22 @@ export function handleGoogleOtherEventType(eventType: string, log: any) { export function normalizeGoogleSETEventType(eventType: string): string { // Map of known event types to clear, concise names const eventTypeMap: { [key: string]: string } = { - 'https://schemas.openid.net/secevent/risc/event-type/verification': 'verification', - 'https://schemas.openid.net/secevent/risc/event-type/sessions-revoked': 'sessions_revoked', - 'https://schemas.openid.net/secevent/risc/event-type/account-disabled': 'account_disabled', - 'https://schemas.openid.net/secevent/risc/event-type/account-enabled': 'account_enabled', - 'https://schemas.openid.net/secevent/risc/event-type/account-purged': 'account_purged', - 'https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required': 'credential_change_required', - 'https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked': 'tokens_revoked', - 'https://schemas.openid.net/secevent/oauth/event-type/token-revoked': 'token_revoked', + 'https://schemas.openid.net/secevent/risc/event-type/verification': + 'verification', + 'https://schemas.openid.net/secevent/risc/event-type/sessions-revoked': + 'sessions_revoked', + 'https://schemas.openid.net/secevent/risc/event-type/account-disabled': + 'account_disabled', + 'https://schemas.openid.net/secevent/risc/event-type/account-enabled': + 'account_enabled', + 'https://schemas.openid.net/secevent/risc/event-type/account-purged': + 'account_purged', + 'https://schemas.openid.net/secevent/risc/event-type/account-credential-change-required': + 'credential_change_required', + 'https://schemas.openid.net/secevent/oauth/event-type/tokens-revoked': + 'tokens_revoked', + 'https://schemas.openid.net/secevent/oauth/event-type/token-revoked': + 'token_revoked', 'https://schemas.openid.net/secevent/risc/event-type/unknown': 'unknown', }; @@ -317,14 +324,20 @@ export function normalizeGoogleSETEventType(eventType: string): string { */ export async function getApplePublicKey(token: string, statsd: StatsD) { try { - const appleCerts = await axios.get(APPLE_PUBLIC_KEYS); + const response = await fetch(APPLE_PUBLIC_KEYS); + if (!response.ok) { + throw new Error( + `Apple public key endpoint responded with ${response.status}` + ); + } + const appleCerts = await response.json(); const jwtHeader = jwt.decode(token, { complete: true })?.header; const keyId = jwtHeader?.kid; if (!keyId) { throw new Error('No valid keyId found.'); } - const publicKey = appleCerts.data.keys.find( + const publicKey = appleCerts.keys.find( (key: { kid: string }) => key.kid === keyId ); @@ -353,17 +366,28 @@ export async function getGooglePublicKey( statsd: StatsD ): Promise<{ pem: string; issuer: string }> { try { - const riscConfig = await axios.get(RISC_CONFIG_URI); - const { jwks_uri: jwksUri, issuer } = riscConfig.data; + const riscConfigResponse = await fetch(RISC_CONFIG_URI); + if (!riscConfigResponse.ok) { + throw new Error( + `Google RISC configuration endpoint responded with ${riscConfigResponse.status}` + ); + } + const { jwks_uri: jwksUri, issuer } = await riscConfigResponse.json(); - const googleCerts = await axios.get(jwksUri); + const googleCertsResponse = await fetch(jwksUri); + if (!googleCertsResponse.ok) { + throw new Error( + `Google public key endpoint responded with ${googleCertsResponse.status}` + ); + } + const googleCerts = await googleCertsResponse.json(); const jwtHeader = jwt.decode(token, { complete: true })?.header; const keyId = jwtHeader.kid; if (!keyId) { throw new Error('No valid keyId found.'); } - const publicKey = googleCerts.data.keys.find( + const publicKey = googleCerts.keys.find( (key: { kid: string }) => key.kid === keyId );