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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,26 @@ For a read-only scope M2M token, use:
- API base configuration points to v6 in dev/local and v5 in prod (for backward compatibility):
- Dev: work-manager/config/constants/development.js.
- Local: work-manager/config/constants/local.js and work-manager/config/constants/local.js.

## Shared authentication and bus client (v6 refresh)

This branch uses `tc-core-library-js` **4.0.0-rc.1**. Use its named
`createJwtVerifier` / `createJwtAuthenticator`, `createM2MClient`, and
`createBusApiClient` exports for shared authentication and bus API publishing.
Service-specific roles, scopes, sessions and ownership checks remain in this service.
The former bus wrapper dependency and local JWT cryptography must not be reintroduced.

RSA verification requires an exact configured issuer and audience (`JWT_AUDIENCE`
for client tokens, `AUTH0_AUDIENCE` for machine/API tokens, or this service's explicit
issuer-policy settings). HMAC secrets must be at least 32 bytes; no default secret
is supplied. Expiry is enforced. Machine identity comes from the shared verifier,
not the presence of scopes. Supply real development credentials through the service's
existing environment configuration before deployment.

Bus publishers use `createBusApiClient(config).postEvent(event)` with the existing
event envelope and optional key. Configure `BUSAPI_URL` for v6 and the existing
OAuth credentials/token provider. The client uses bounded HTTPS requests and does
not retry POSTs automatically. Application retry/outbox behavior remains local.

See the [core migration guide](https://github.com/topcoder-platform/tc-core-library-js/blob/v4.0.0-rc.1/MIGRATION.md)
for the API mapping, compatibility requirements, and rollout scope.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,7 @@
"qs": "^6.15.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"tc-core-library-js": "topcoder-platform/tc-core-library-js.git#master",
"tc-bus-api-wrapper": "github:topcoder-platform/tc-bus-api-wrapper.git",
"tc-core-library-js": "github:topcoder-platform/tc-core-library-js#v4.0.0-rc.1",
"uuid": "^14.0.1"
},
"devDependencies": {
Expand Down
251 changes: 9 additions & 242 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/shared/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { PrismaService } from 'src/shared/modules/global/prisma.service';
import { GroupStatus } from 'src/shared/enums/groupStatus.enum';
import { GroupRole } from 'src/shared/enums/groupRole.enum';
import { MemberShipType } from 'src/shared/enums/memberShipType.enum';
import * as busApi from 'tc-bus-api-wrapper';
import { createBusApiClient as busApi } from 'tc-core-library-js';
import { CommonConfig } from 'src/shared/config/common.config';
import { validate } from 'uuid';
import { JwtUser } from 'src/shared/modules/global/jwt.service';
Expand Down
26 changes: 6 additions & 20 deletions src/shared/modules/global/eventBus.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { createBusApiClient } from 'tc-core-library-js';
import {
HttpStatus,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import { M2MService } from './m2m.service';
import { CommonConfig } from 'src/shared/config/common.config';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';

/**
* Event bus message.
Expand Down Expand Up @@ -46,12 +45,11 @@ export class EventBusService {
) {}

/**
* Send email message to Event bus.
* Send email through the shared tc-core-library-js bus publisher.
* @param payload send email payload
*/
async sendEmail(payload: EventBusSendEmailPayload): Promise<void> {
// Get M2m token
const token = await this.m2mService.getM2MToken();
// build event bus message
const msg = new EventBusMessage<EventBusSendEmailPayload>();
msg.topic = 'external.action.email';
Expand All @@ -61,22 +59,10 @@ export class EventBusService {
// send message to event bus
const url = CommonConfig.BUSAPI_URL;
try {
const response = await firstValueFrom(
this.httpService.post(url, msg, {
headers: {
Authorization: 'Bearer ' + token,
},
}),
);

/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
if (
response.status !== HttpStatus.OK &&
response.status !== HttpStatus.NO_CONTENT
) {
throw new Error(`Event bus status code: ${response.status}`);
}
/* eslint-enable @typescript-eslint/no-unsafe-enum-comparison */
await createBusApiClient({
BUSAPI_URL: url,
getToken: () => this.m2mService.getM2MToken(),
}).postEvent(msg);
} catch (e) {
this.logger.error(`Event bus failed with error: ${e.message}`);
throw new InternalServerErrorException(
Expand Down
30 changes: 17 additions & 13 deletions src/shared/modules/global/jwt.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createRequire } from 'node:module';
const sharedRequire = createRequire(require.resolve('tc-core-library-js'));
const { JwksClient } = sharedRequire('jwks-rsa');
import { generateKeyPairSync } from 'crypto';
import { UnauthorizedException } from '@nestjs/common';
import { sign } from 'jsonwebtoken';
Expand All @@ -10,7 +13,7 @@ const ALLOWED_ISSUER = 'https://issuer.example/';
const SECOND_ALLOWED_ISSUER = 'https://second-issuer.example/';
const AUDIENCE = 'https://audience.example/';
const USER_AUDIENCE = 'https://app.example/v6';
const AUTH_SECRET = 'test-shared-auth-secret';
const AUTH_SECRET = 'test-shared-auth-secret-with-32-bytes';

const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
Expand All @@ -29,6 +32,7 @@ describe('JwtService validation', () => {

beforeEach(() => {
process.env.NODE_ENV = 'production';
process.env.JWT_AUDIENCE = USER_AUDIENCE;
AuthConfig.authSecret = AUTH_SECRET;
AuthConfig.validIssuers = [ALLOWED_ISSUER, SECOND_ALLOWED_ISSUER];
AuthConfig.jwt.audience = AUDIENCE;
Expand Down Expand Up @@ -56,8 +60,8 @@ describe('JwtService validation', () => {

it('accepts an RS256 admin token with a non-M2M audience', async () => {
const signingKeySpy = jest
.spyOn(service as any, 'getSigningKey')
.mockResolvedValue(publicKey);
.spyOn(JwksClient.prototype, 'getSigningKey')
.mockResolvedValue({ getPublicKey: () => publicKey.export({ type: 'spki', format: 'pem' }) });
const token = sign(
{
roles: [UserRole.Admin],
Expand All @@ -83,13 +87,12 @@ describe('JwtService validation', () => {
}),
);
expect(signingKeySpy).toHaveBeenCalledWith(
SECOND_ALLOWED_ISSUER,
'test-key-id',
);
});

it('accepts an RS256 client-credentials token with the configured audience', async () => {
jest.spyOn(service as any, 'getSigningKey').mockResolvedValue(publicKey);
jest.spyOn(JwksClient.prototype, 'getSigningKey').mockResolvedValue({ getPublicKey: () => publicKey.export({ type: 'spki', format: 'pem' }) });
const token = sign(
{
scope: Scope.AllGroups,
Expand All @@ -107,6 +110,8 @@ describe('JwtService validation', () => {
);

await expect(service.validateToken(token)).resolves.toEqual({
roles: [],
handle: undefined,
scopes: expect.arrayContaining([
Scope.AllGroups,
Scope.ReadGroups,
Expand Down Expand Up @@ -136,7 +141,7 @@ describe('JwtService validation', () => {
});

it('rejects an RS256 client-credentials token with the wrong audience', async () => {
jest.spyOn(service as any, 'getSigningKey').mockResolvedValue(publicKey);
jest.spyOn(JwksClient.prototype, 'getSigningKey').mockResolvedValue({ getPublicKey: () => publicKey.export({ type: 'spki', format: 'pem' }) });
const token = sign(
{
scope: Scope.ReadGroups,
Expand All @@ -159,7 +164,7 @@ describe('JwtService validation', () => {
});

it('rejects an RS256 client-credentials token without an audience', async () => {
jest.spyOn(service as any, 'getSigningKey').mockResolvedValue(publicKey);
jest.spyOn(JwksClient.prototype, 'getSigningKey').mockResolvedValue({ getPublicKey: () => publicKey.export({ type: 'spki', format: 'pem' }) });
const token = sign(
{
scope: Scope.ReadGroups,
Expand All @@ -181,7 +186,7 @@ describe('JwtService validation', () => {
});

it('still checks audience when client-credentials tokens contain user claims', async () => {
jest.spyOn(service as any, 'getSigningKey').mockResolvedValue(publicKey);
jest.spyOn(JwksClient.prototype, 'getSigningKey').mockResolvedValue({ getPublicKey: () => publicKey.export({ type: 'spki', format: 'pem' }) });
const token = sign(
{
scope: Scope.ReadGroups,
Expand All @@ -206,7 +211,7 @@ describe('JwtService validation', () => {

it('rejects a client-credentials token when AUTH0_AUDIENCE is unavailable', async () => {
AuthConfig.jwt.audience = undefined;
const signingKeySpy = jest.spyOn(service as any, 'getSigningKey');
const signingKeySpy = jest.spyOn(JwksClient.prototype, 'getSigningKey');
const token = sign(
{
scope: Scope.ReadGroups,
Expand All @@ -226,12 +231,11 @@ describe('JwtService validation', () => {
await expect(service.validateToken(token)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(signingKeySpy).not.toHaveBeenCalled();
});

it('does not require AUTH0_AUDIENCE for an RS256 user token', async () => {
AuthConfig.jwt.audience = undefined;
jest.spyOn(service as any, 'getSigningKey').mockResolvedValue(publicKey);
jest.spyOn(JwksClient.prototype, 'getSigningKey').mockResolvedValue({ getPublicKey: () => publicKey.export({ type: 'spki', format: 'pem' }) });
const token = sign(
{ roles: [UserRole.Admin], userId: '12345' },
privateKey,
Expand All @@ -254,7 +258,7 @@ describe('JwtService validation', () => {
});

it('rejects an unlisted issuer before requesting its signing key', async () => {
const signingKeySpy = jest.spyOn(service as any, 'getSigningKey');
const signingKeySpy = jest.spyOn(JwksClient.prototype, 'getSigningKey');
const token = sign({ roles: [UserRole.Admin] }, privateKey, {
algorithm: 'RS256',
keyid: 'test-key-id',
Expand All @@ -270,7 +274,7 @@ describe('JwtService validation', () => {
});

it('rejects an RS256 token without a key ID', async () => {
const signingKeySpy = jest.spyOn(service as any, 'getSigningKey');
const signingKeySpy = jest.spyOn(JwksClient.prototype, 'getSigningKey');
const token = sign({ roles: [UserRole.Admin] }, privateKey, {
algorithm: 'RS256',
issuer: ALLOWED_ISSUER,
Expand Down
Loading
Loading