Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/payments/api/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -123,6 +124,8 @@ import { PaymentsMetricsAggregatorService } from '@fxa/payments/metrics-aggregat
NimbusManagerConfig,
NimbusClient,
NimbusClientConfig,
MeteringConfig,
MeteringAuthGuard,
],
})
export class AppModule {}
2 changes: 2 additions & 0 deletions libs/entitlements/metering/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
149 changes: 149 additions & 0 deletions libs/entitlements/metering/src/lib/metering-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
): Promise<MeteringAuthGuard> {
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
);
});
});
});
95 changes: 95 additions & 0 deletions libs/entitlements/metering/src/lib/metering-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -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 <token>` against
* `MeteringConfig.clients` `{ clientId: secret }`
*/
@Injectable()
export class MeteringAuthGuard implements CanActivate {
private readonly clientIdByToken: Map<string, string>;

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<boolean> {
const request = context.switchToHttp().getRequest<RequestWithClient>();
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<RequestWithClient>()
)
);
Original file line number Diff line number Diff line change
@@ -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();
});
});
14 changes: 14 additions & 0 deletions libs/entitlements/metering/src/lib/utils/extractBearerToken.ts
Original file line number Diff line number Diff line change
@@ -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;
}