diff --git a/apps/payments/api/src/app/app.module.ts b/apps/payments/api/src/app/app.module.ts index 8f7ff1a477f..902ae0df13e 100644 --- a/apps/payments/api/src/app/app.module.ts +++ b/apps/payments/api/src/app/app.module.ts @@ -8,6 +8,7 @@ import { BillingAndSubscriptionsService, } from '@fxa/payments/api-server'; import { AuthModule } from '@fxa/payments/auth'; +import { MeteringAuthGuard, MeteringConfig } from '@fxa/entitlements/metering'; import { CmsWebhooksController, CmsWebhookService, @@ -123,6 +124,8 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat NimbusManagerConfig, NimbusClient, NimbusClientConfig, + MeteringConfig, + MeteringAuthGuard, ], }) export class AppModule {} diff --git a/libs/entitlements/metering/src/index.ts b/libs/entitlements/metering/src/index.ts index 6b87f8950bb..103cf344a12 100644 --- a/libs/entitlements/metering/src/index.ts +++ b/libs/entitlements/metering/src/index.ts @@ -3,3 +3,5 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ export * from './lib/metering.config'; +export * from './lib/metering-auth.guard'; +export * from './lib/utils/extractBearerToken'; diff --git a/libs/entitlements/metering/src/lib/metering-auth.guard.spec.ts b/libs/entitlements/metering/src/lib/metering-auth.guard.spec.ts new file mode 100644 index 00000000000..702e4ba6921 --- /dev/null +++ b/libs/entitlements/metering/src/lib/metering-auth.guard.spec.ts @@ -0,0 +1,149 @@ +/* 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 { UnauthorizedException } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; + +import { + AuthenticatedMeteringClient, + MeteringAuthGuard, + meteringClientFromRequest, +} from './metering-auth.guard'; +import { MeteringConfig } from './metering.config'; + +const REQUEST_CLIENT_KEY = '__meteringClient'; + +function buildRequest(authorization?: string): { + headers: { authorization?: string }; + [REQUEST_CLIENT_KEY]?: AuthenticatedMeteringClient; +} { + return { headers: authorization ? { authorization } : {} }; +} + +async function buildGuard( + clients: Record +): Promise { + const moduleRef = await Test.createTestingModule({ + providers: [ + MeteringAuthGuard, + { + provide: MeteringConfig, + useValue: { openmeterBaseUrl: 'http://example.com', clients }, + }, + ], + }).compile(); + return moduleRef.get(MeteringAuthGuard); +} + +describe('MeteringAuthGuard', () => { + const RP_ID = 'vpn'; + const RP_SECRET = 'super-secret-token-with-enough-entropy-aaaaaaaa'; + + describe('construction', () => { + it('throws if two clients share the same secret', async () => { + await expect( + buildGuard({ vpn: RP_SECRET, relay: RP_SECRET }) + ).rejects.toThrow(/share the same secret/); + }); + + it('throws if a client has an empty secret', async () => { + await expect(buildGuard({ [RP_ID]: ' ' })).rejects.toThrow( + /empty secret/ + ); + }); + + it('detects duplicate secrets that differ only by surrounding whitespace', async () => { + await expect( + buildGuard({ vpn: RP_SECRET, relay: ` ${RP_SECRET} ` }) + ).rejects.toThrow(/share the same secret/); + }); + }); + + describe('authorize', () => { + it('rejects requests without an authorization header', async () => { + const meteringAuthGuard = await buildGuard({ [RP_ID]: RP_SECRET }); + expect(() => meteringAuthGuard.authorize(buildRequest())).toThrow( + UnauthorizedException + ); + }); + + it('rejects requests with non-bearer auth headers', async () => { + const meteringAuthGuard = await buildGuard({ [RP_ID]: RP_SECRET }); + expect(() => + meteringAuthGuard.authorize(buildRequest('Basic abc')) + ).toThrow(UnauthorizedException); + }); + + it('rejects unknown bearer tokens', async () => { + const meteringAuthGuard = await buildGuard({ [RP_ID]: RP_SECRET }); + expect(() => + meteringAuthGuard.authorize(buildRequest('Bearer wrong-token')) + ).toThrow(UnauthorizedException); + }); + + it('rejects when no clients are configured', async () => { + const meteringAuthGuard = await buildGuard({}); + expect(() => + meteringAuthGuard.authorize(buildRequest(`Bearer ${RP_SECRET}`)) + ).toThrow(UnauthorizedException); + }); + + it('attaches the matched clientId to the request on success', async () => { + const meteringAuthGuard = await buildGuard({ [RP_ID]: RP_SECRET }); + const request = buildRequest(`Bearer ${RP_SECRET}`); + + const result = meteringAuthGuard.authorize(request); + + expect(result).toBe(true); + expect(request[REQUEST_CLIENT_KEY]).toEqual({ clientId: RP_ID }); + }); + + it('matches a config secret padded with surrounding whitespace', async () => { + const meteringAuthGuard = await buildGuard({ + [RP_ID]: ` ${RP_SECRET} `, + }); + const request = buildRequest(`Bearer ${RP_SECRET}`); + + meteringAuthGuard.authorize(request); + + expect(request[REQUEST_CLIENT_KEY]).toEqual({ clientId: RP_ID }); + }); + + it('matches case-insensitively on the Bearer prefix', async () => { + const meteringAuthGuard = await buildGuard({ [RP_ID]: RP_SECRET }); + const request = buildRequest(`bearer ${RP_SECRET}`); + + meteringAuthGuard.authorize(request); + + expect(request[REQUEST_CLIENT_KEY]).toEqual({ clientId: RP_ID }); + }); + + it('overwrites any client already present on the request', async () => { + const meteringAuthGuard = await buildGuard({ [RP_ID]: RP_SECRET }); + const request = { + ...buildRequest(`Bearer ${RP_SECRET}`), + [REQUEST_CLIENT_KEY]: { clientId: 'attacker' }, + }; + + meteringAuthGuard.authorize(request); + + expect(request[REQUEST_CLIENT_KEY]).toEqual({ clientId: RP_ID }); + }); + }); + + describe('meteringClientFromRequest', () => { + it('returns the authenticated client attached to the request', () => { + const client = { clientId: RP_ID }; + expect( + meteringClientFromRequest({ [REQUEST_CLIENT_KEY]: client }) + ).toEqual(client); + }); + + it('throws when no client is attached to the request', () => { + expect(() => meteringClientFromRequest({})).toThrow( + UnauthorizedException + ); + }); + }); +}); diff --git a/libs/entitlements/metering/src/lib/metering-auth.guard.ts b/libs/entitlements/metering/src/lib/metering-auth.guard.ts new file mode 100644 index 00000000000..ef773dc6e55 --- /dev/null +++ b/libs/entitlements/metering/src/lib/metering-auth.guard.ts @@ -0,0 +1,95 @@ +/* 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 { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, + createParamDecorator, +} from '@nestjs/common'; +import type { Request } from 'express'; + +import { MeteringConfig } from './metering.config'; +import { extractBearerToken } from './utils/extractBearerToken'; + +export interface AuthenticatedMeteringClient { + clientId: string; +} + +const REQUEST_CLIENT_KEY = '__meteringClient'; + +interface RequestWithClient extends Request { + [REQUEST_CLIENT_KEY]?: AuthenticatedMeteringClient; +} + +/** + * Validates `Authorization: Bearer ` against + * `MeteringConfig.clients` `{ clientId: secret }` + */ +@Injectable() +export class MeteringAuthGuard implements CanActivate { + private readonly clientIdByToken: Map; + + constructor(meteringConfig: MeteringConfig) { + this.clientIdByToken = new Map(); + for (const [clientId, secret] of Object.entries( + meteringConfig.clients ?? {} + )) { + const normalizedSecret = typeof secret === 'string' ? secret.trim() : ''; + if (normalizedSecret.length === 0) { + throw new Error( + `MeteringConfig.clients[${clientId}] has an empty secret` + ); + } + const existing = this.clientIdByToken.get(normalizedSecret); + if (existing !== undefined) { + throw new Error( + `MeteringConfig.clients[${clientId}] and clients[${existing}] share the same secret` + ); + } + this.clientIdByToken.set(normalizedSecret, clientId); + } + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + return this.authorize(request); + } + + authorize(request: { + headers: Request['headers']; + [REQUEST_CLIENT_KEY]?: AuthenticatedMeteringClient; + }): boolean { + const token = extractBearerToken(request.headers['authorization']); + if (!token) { + throw new UnauthorizedException(); + } + + const clientId = this.clientIdByToken.get(token); + if (!clientId) { + throw new UnauthorizedException(); + } + + request[REQUEST_CLIENT_KEY] = { clientId }; + return true; + } +} + +export function meteringClientFromRequest(request: { + [REQUEST_CLIENT_KEY]?: AuthenticatedMeteringClient; +}): AuthenticatedMeteringClient { + const authenticatedMeteringClient = request[REQUEST_CLIENT_KEY]; + if (!authenticatedMeteringClient) { + throw new UnauthorizedException(); + } + return authenticatedMeteringClient; +} + +export const CurrentMeteringClient = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthenticatedMeteringClient => + meteringClientFromRequest( + context.switchToHttp().getRequest() + ) +); diff --git a/libs/entitlements/metering/src/lib/utils/extractBearerToken.spec.ts b/libs/entitlements/metering/src/lib/utils/extractBearerToken.spec.ts new file mode 100644 index 00000000000..d9a7db6fe31 --- /dev/null +++ b/libs/entitlements/metering/src/lib/utils/extractBearerToken.spec.ts @@ -0,0 +1,41 @@ +/* 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 { extractBearerToken } from './extractBearerToken'; + +describe('extractBearerToken', () => { + it('returns the token after a Bearer prefix', () => { + expect(extractBearerToken('Bearer abc123')).toBe('abc123'); + }); + + it('matches case-insensitively', () => { + expect(extractBearerToken('bearer abc123')).toBe('abc123'); + expect(extractBearerToken('BEARER abc123')).toBe('abc123'); + }); + + it('strips surrounding whitespace from the header and the token', () => { + expect(extractBearerToken(' Bearer abc123 ')).toBe('abc123'); + }); + + it('uses the first value when the header is an array', () => { + expect(extractBearerToken(['Bearer xyz', 'Bearer other'])).toBe('xyz'); + }); + + it('returns null when the header is missing', () => { + expect(extractBearerToken(undefined)).toBeNull(); + }); + + it('returns null when the header does not start with Bearer', () => { + expect(extractBearerToken('Token abc')).toBeNull(); + expect(extractBearerToken('abc')).toBeNull(); + }); + + it('returns null when the token portion is empty', () => { + expect(extractBearerToken('Bearer ')).toBeNull(); + }); + + it('returns null when the token contains internal whitespace', () => { + expect(extractBearerToken('Bearer abc def')).toBeNull(); + }); +}); diff --git a/libs/entitlements/metering/src/lib/utils/extractBearerToken.ts b/libs/entitlements/metering/src/lib/utils/extractBearerToken.ts new file mode 100644 index 00000000000..e6594aa3cf2 --- /dev/null +++ b/libs/entitlements/metering/src/lib/utils/extractBearerToken.ts @@ -0,0 +1,14 @@ +/* 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 function extractBearerToken( + header: string | string[] | undefined +): string | null { + const value = Array.isArray(header) ? header[0] : header; + if (typeof value !== 'string') { + return null; + } + const match = /^Bearer\s+(\S+)$/i.exec(value.trim()); + return match ? match[1] : null; +}