Skip to content
Merged
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
98 changes: 98 additions & 0 deletions src/shared/modules/global/m2m.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,111 @@
import { createServer, Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { LoggerService } from './logger.service';
import { Scope } from 'src/shared/enums/scopes.enum';
import { M2MService } from './m2m.service';

describe('M2MService', () => {
let service: M2MService;
const originalEnv = process.env;

beforeEach(() => {
process.env = {
...originalEnv,
AUTH0_URL: 'https://auth.example.test/oauth/token',
AUTH0_AUDIENCE: 'https://api.example.test/',
AUTH0_PROXY_SERVER_URL: 'https://proxy.example.test/token',
AUTH0_CLIENT_ID: 'test-client',
AUTH0_CLIENT_SECRET: 'test-secret',
};
service = new M2MService();
});

afterEach(() => {
process.env = originalEnv;
jest.restoreAllMocks();
});

describe('getM2MToken with the installed tc-core library', () => {
let server: Server;
let requests: { url?: string; body: Record<string, unknown> }[];
let responseStatus: number;

beforeEach(async () => {
requests = [];
responseStatus = 200;
server = createServer((request, response) => {
let body = '';
request.setEncoding('utf8');
request.on('data', (chunk: string) => {
body += chunk;
});
request.on('end', () => {
requests.push({
url: request.url,
body: JSON.parse(body) as Record<string, unknown>,
});
response.writeHead(responseStatus, {
'Content-Type': 'application/json',
});
response.end(
JSON.stringify(
responseStatus === 200
? { access_token: 'test-machine-token', expires_in: 3600 }
: { error: 'invalid_client' },
),
);
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', resolve);
});
const { port } = server.address() as AddressInfo;
process.env.AUTH0_PROXY_SERVER_URL = `http://127.0.0.1:${port}/token`;
service = new M2MService();
});

afterEach(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
});
});

it('acquires and caches a token through the configured Auth0 proxy', async () => {
await expect(service.getM2MToken()).resolves.toBe('test-machine-token');
await expect(service.getM2MToken()).resolves.toBe('test-machine-token');
expect(requests).toEqual([
{
url: '/token',
body: {
grant_type: 'client_credentials',
client_id: 'test-client',
client_secret: 'test-secret',
audience: 'https://api.example.test/',
auth0_url: 'https://auth.example.test/oauth/token',
},
},
]);
});

it('rejects missing credentials before making a token request', async () => {
delete process.env.AUTH0_CLIENT_SECRET;
await expect(service.getM2MToken()).rejects.toThrow(
'M2M credentials are not configured.',
);
expect(requests).toHaveLength(0);
});

it('preserves the service error when the token endpoint rejects credentials', async () => {
responseStatus = 401;
jest.spyOn(LoggerService.prototype, 'error').mockImplementation(() => {});
await expect(service.getM2MToken()).rejects.toThrow(
'Failed to retrieve M2M token.',
);
expect(requests).toHaveLength(1);
});
});

describe('expandScopes', () => {
it('expands hierarchy for all:projects', () => {
const expandedScopes = service.expandScopes([Scope.PROJECTS_ALL]);
Expand Down
28 changes: 9 additions & 19 deletions src/shared/modules/global/m2m.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { createM2MClient, type M2MClient } from 'tc-core-library-js';
import {
Scope,
SCOPE_HIERARCHY,
Expand All @@ -13,10 +14,6 @@ import { LoggerService } from './logger.service';
* Provides M2M token acquisition through tc-core/Auth0 and scope utilities
* used to classify machine tokens and evaluate scope-based access.
*/
// tc-core-library-js is CommonJS-only.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tcCore = require('tc-core-library-js');

type TokenPayload = Record<string, unknown>;

@Injectable()
Expand All @@ -28,34 +25,27 @@ type TokenPayload = Record<string, unknown>;
*/
export class M2MService {
private readonly logger = LoggerService.forRoot('M2MService');
private readonly m2mClient: any;
private readonly m2mClient: M2MClient;

// TODO (security): AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET are read at call-time in getM2MToken() rather than validated at startup. A missing secret will only surface at runtime when a token is first requested.
// TODO (quality): m2mClient is typed as 'any'. Define an interface for the tc-core M2M client to enable type safety.
/**
* Creates the tc-core M2M client when tc-core auth bindings are available.
* Creates the tc-core M2M client using its supported top-level factory.
*/
constructor() {
if (tcCore?.auth?.m2m) {
this.m2mClient = tcCore.createM2MClient({
AUTH0_URL: process.env.AUTH0_URL,
AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE,
AUTH0_PROXY_SERVER_URL: process.env.AUTH0_PROXY_SERVER_URL,
});
}
this.m2mClient = createM2MClient({
AUTH0_URL: process.env.AUTH0_URL,
AUTH0_AUDIENCE: process.env.AUTH0_AUDIENCE,
AUTH0_PROXY_SERVER_URL: process.env.AUTH0_PROXY_SERVER_URL,
});
}

/**
* Fetches an M2M token from Auth0 through tc-core.
*
* @returns {Promise<string>} Machine token string.
* @throws {InternalServerErrorException} When the client is not initialized, credentials are missing, or token retrieval fails.
* @throws {InternalServerErrorException} When credentials are missing or token retrieval fails.
*/
async getM2MToken(): Promise<string> {
if (!this.m2mClient) {
throw new InternalServerErrorException('M2M client is not initialized.');
}

const clientId = process.env.AUTH0_CLIENT_ID;
const clientSecret = process.env.AUTH0_CLIENT_SECRET;

Expand Down
Loading