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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ HTTP_MAX_REDIRECTS=5
HTTP_TIMEOUT=5000
JWT_SECRET=<JWT_SECRET>
JWT_EXPIRES_IN=3600
JOB_TOKEN_EXPIRES_IN=24h
LDAP_URL="ldaps://ldap.server.com:636/"
LDAP_BIND_DN="<USERNAME>@server.com"
LDAP_BIND_CREDENTIALS=<PASSWORD>
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ Valid environment variables for the .env file. See [.env.example](/.env.example)
| `HTTP_TIMEOUT` | number | Yes | Timeout for HTTP requests in ms. | 5000 |
| `JWT_SECRET` | string | | The secret for your JWT token, used for authorization. | |
| `JWT_EXPIRES_IN` | number | Yes | How long, in seconds, the JWT token is valid. | 3600 |
| `JOB_TOKEN_EXPIRES_IN` | string | Yes | How long purpose-created job JWTs are valid (e.g. "24h", "7d"). Falls back to `JWT_EXPIRES_IN` when not set. | |
| `LDAP_URL` | string | Yes | The URL to your LDAP server. | |
| `LDAP_BIND_DN` | string | Yes | Bind*DN for your LDAP server. | |
| `LDAP_BIND_CREDENTIALS` | string | Yes | Credentials for your LDAP server. | |
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Valid environment variables for the .env file. See [.env.example](/.env.example)
| `HTTP_TIMEOUT` | number | Yes | Timeout for HTTP requests in ms. | 5000 |
| `JWT_SECRET` | string | | The secret for your JWT token, used for authorization. | |
| `JWT_EXPIRES_IN` | number | Yes | How long, in seconds, the JWT token is valid. | 3600 |
| `JOB_TOKEN_EXPIRES_IN` | string | Yes | How long purpose-created job JWTs are valid (e.g. "24h", "7d"). Falls back to `JWT_EXPIRES_IN` when not set. | |
| `LDAP_URL` | string | Yes | The URL to your LDAP server. | |
| `LDAP_BIND_DN` | string | Yes | Bind*DN for your LDAP server. | |
| `LDAP_BIND_CREDENTIALS` | string | Yes | Credentials for your LDAP server. | |
Expand Down
1 change: 1 addition & 0 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ const configuration = () => {
secret: process.env.JWT_SECRET,
expiresIn: parseInt(process.env.JWT_EXPIRES_IN ?? "3600", 10),
neverExpires: process.env.JWT_NEVER_EXPIRES ?? "100y",
jobTokenExpiresIn: process.env.JOB_TOKEN_EXPIRES_IN || undefined,
},
ldap: {
server: {
Expand Down
108 changes: 108 additions & 0 deletions src/config/job-config/handlebar-utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { compileJobTemplate, registerHelpers } from "./handlebar-utils";
import { JobClass } from "../../jobs/schemas/job.schema";
import { CreateJobDto } from "../../jobs/dto/create-job.dto";

describe("handlebar-utils", () => {
beforeAll(() => {
registerHelpers();
});

const createMockRequest = (): CreateJobDto => ({
type: "test",
jobParams: {},
});

describe("compileJobTemplate", () => {
it("should compile a simple template", () => {
const template = compileJobTemplate("Hello {{job.id}}");
const job = { id: "test-job-123" } as JobClass;
const context = {
request: createMockRequest(),
job,
env: {},
datasets: [],
};

const result = template(context);
expect(result).toBe("Hello test-job-123");
});

it("should make userToken available in templates", () => {
const template = compileJobTemplate("{{userToken}}");
const job = { id: "test-job-123" } as JobClass;
const context = {
request: createMockRequest(),
job,
env: {},
datasets: [],
userToken: "my-jwt-token-abc123",
};

const result = template(context);
expect(result).toBe("my-jwt-token-abc123");
});

it("should render empty string when userToken is undefined", () => {
const template = compileJobTemplate("Bearer {{userToken}}");
const job = { id: "test-job-123" } as JobClass;
const context = {
request: createMockRequest(),
job,
env: {},
datasets: [],
};

const result = template(context);
expect(result).toBe("Bearer ");
});

it("should use userToken in authorization header template", () => {
const template = compileJobTemplate("Bearer {{userToken}}");
const job = { id: "test-job-123" } as JobClass;
const context = {
request: createMockRequest(),
job,
env: {},
datasets: [],
userToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test",
};

const result = template(context);
expect(result).toBe("Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test");
});

it("should combine userToken with job properties", () => {
const template = compileJobTemplate(
"Job {{job.id}} - Token: {{userToken}}",
);
const job = { id: "job-456" } as JobClass;
const context = {
request: createMockRequest(),
job,
env: {},
datasets: [],
userToken: "token-xyz",
};

const result = template(context);
expect(result).toBe("Job job-456 - Token: token-xyz");
});

it("should handle userToken in URL template", () => {
const template = compileJobTemplate(
"http://api.example.com/data?token={{userToken}}",
);
const job = { id: "test-job" } as JobClass;
const context = {
request: createMockRequest(),
job,
env: {},
datasets: [],
userToken: "my-secret-token",
};

const result = template(context);
expect(result).toBe("http://api.example.com/data?token=my-secret-token");
});
});
});
87 changes: 87 additions & 0 deletions src/config/job-config/token.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "src/users/users.service";
import { generateJobUserToken } from "./token.utils";
import { JWTUser } from "src/auth/interfaces/jwt-user.interface";

describe("token.utils", () => {
let usersService: jest.Mocked<UsersService>;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: UsersService,
useValue: {
findByUsername2JWTUser: jest.fn(),
createUserJWT: jest.fn(),
},
},
],
}).compile();

usersService = module.get<UsersService>(
UsersService,
) as jest.Mocked<UsersService>;
});

describe("generateJobUserToken", () => {
it("should return undefined when ownerUser is undefined", async () => {
const result = await generateJobUserToken(usersService, undefined);
expect(result).toBeUndefined();
expect(usersService.findByUsername2JWTUser).not.toHaveBeenCalled();
});

it("should return undefined when user is not found", async () => {
usersService.findByUsername2JWTUser.mockResolvedValue(null);

const result = await generateJobUserToken(
usersService,
"nonexistent-user",
);
expect(result).toBeUndefined();
expect(usersService.findByUsername2JWTUser).toHaveBeenCalledWith(
"nonexistent-user",
);
});

it("should return undefined when JWT generation fails", async () => {
const mockUser: JWTUser = {
_id: "test-id",
username: "testuser",
email: "test@example.com",
currentGroups: ["public"],
};
usersService.findByUsername2JWTUser.mockResolvedValue(mockUser);
usersService.createUserJWT.mockResolvedValue(null);

const result = await generateJobUserToken(usersService, "testuser");
expect(result).toBeUndefined();
expect(usersService.createUserJWT).toHaveBeenCalledWith(
mockUser,
undefined,
);
});

it("should return JWT token when successful", async () => {
const mockUser: JWTUser = {
_id: "test-id",
username: "testuser",
email: "test@example.com",
currentGroups: ["public"],
};
const mockJwt = { jwt: "test-jwt-token" };
usersService.findByUsername2JWTUser.mockResolvedValue(mockUser);
usersService.createUserJWT.mockResolvedValue(mockJwt);

const result = await generateJobUserToken(usersService, "testuser");
expect(result).toBe("test-jwt-token");
expect(usersService.findByUsername2JWTUser).toHaveBeenCalledWith(
"testuser",
);
expect(usersService.createUserJWT).toHaveBeenCalledWith(
mockUser,
undefined,
);
});
});
});
54 changes: 54 additions & 0 deletions src/config/job-config/token.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Logger } from "@nestjs/common";
import { UsersService } from "src/users/users.service";

/**
* Generates a SciCat JWT token for job execution.
* This token is used by job actions (e.g., URL actions) to make authenticated
* requests to other SciCat services on behalf of the user.
*
* @param usersService - UsersService instance for user lookup and token generation
* @param ownerUser - The username of the job owner (from ownerUser field)
* @param expiresIn - Optional token expiry duration (e.g. "24h", "7d"). Falls back to jwt.expiresIn.
* @returns JWT token string, or undefined if user not found or ownerUser is undefined
*/
export async function generateJobUserToken(
usersService: UsersService,
ownerUser: string | undefined,
expiresIn?: string,
): Promise<string | undefined> {
if (!ownerUser) {
Logger.debug("No ownerUser provided for token generation", "TokenUtils");
return undefined;
}

try {
// Look up the user by username to get their details
const jwtUser = await usersService.findByUsername2JWTUser(ownerUser);

if (!jwtUser) {
Logger.warn(`User not found for ownerUser: ${ownerUser}`, "TokenUtils");
return undefined;
}

// Generate JWT using the default expiration from jwt.expiresIn config
const jwtResult = await usersService.createUserJWT(jwtUser, expiresIn);

if (!jwtResult?.jwt) {
Logger.warn(
`Failed to generate JWT for ownerUser: ${ownerUser}`,
"TokenUtils",
);
return undefined;
}

Logger.debug(`Generated JWT for ownerUser: ${ownerUser}`, "TokenUtils");
return jwtResult.jwt;
} catch (error) {
Logger.error(
`Error generating JWT for ownerUser: ${ownerUser}`,
error,
"TokenUtils",
);
return undefined;
}
}
2 changes: 1 addition & 1 deletion src/jobs/dto/output-job-v4.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { PartialType } from "@nestjs/swagger";
import { CreateJobDto } from "./create-job.dto";
import {
IsArray,
IsDateString,
IsObject,
IsOptional,
IsString,
IsArray,
} from "class-validator";
import { PartialOutputDatasetDto } from "src/datasets/dto/output-dataset.dto";

Expand Down
28 changes: 26 additions & 2 deletions src/jobs/jobs.controller.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { loadDatasets } from "src/config/job-config/actions/actionutils";
import { DatasetClass } from "src/datasets/schemas/dataset.schema";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { generateJobUserToken } from "../config/job-config/token.utils";

@Injectable()
export class JobsControllerUtils {
Expand Down Expand Up @@ -467,10 +468,23 @@ export class JobsControllerUtils {
await validateActions(jobConfig.create.actions, contextWithDatasets);
// Create actual job in database
const createdJobInstance = await this.jobsService.create(jobInstance);

// Generate short-lived JWT for job execution using the owner's username
const jobObject = toObject(createdJobInstance) as JobClass;
const jobTokenExpiresIn = this.configService.get<string>(
"jwt.jobTokenExpiresIn",
);
const userToken = await generateJobUserToken(
this.usersService,
jobObject.ownerUser,
jobTokenExpiresIn,
);

// Perform the action that is specified in the create portion of the job configuration
const performContext = {
...contextWithDatasets,
job: toObject(createdJobInstance) as JobClass,
job: jobObject,
userToken, // Inject the generated short-lived JWT
};
await performActions(jobConfig.create.actions, performContext);
return createdJobInstance;
Expand Down Expand Up @@ -538,9 +552,19 @@ export class JobsControllerUtils {
// Perform the action that is specified in the update portion of the job configuration
if (updatedJob !== null) {
await this.checkConfigVersion(jobConfig, updatedJob);

// Generate short-lived JWT for job execution using the owner's username
const jobObject = toObject(updatedJob) as JobClass;
const userToken = await generateJobUserToken(
this.usersService,
jobObject.ownerUser,
this.configService.get<string>("jwt.jobTokenExpiresIn"),
);

const performContext = {
...contextWithDatasets,
job: toObject(updatedJob) as JobClass,
job: jobObject,
userToken, // Inject the generated short-lived JWT
};
await performActions(jobConfig.update.actions, performContext);
}
Expand Down
3 changes: 3 additions & 0 deletions src/jobs/jobs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,19 @@ export class JobsService {

async create(createJobDto: JobClass): Promise<JobDocument> {
const username = this.getUsername();

const jobData = addCreatedByFields(createJobDto, username);
const statusCode =
createJobDto.statusCode ||
this.configService.get<string>("jobDefaultStatusCode")!;
const statusMessage =
createJobDto.statusMessage ||
this.configService.get<string>("jobDefaultStatusMessage")!;

const createdJob = new this.jobModel(
this.addStatusFields(jobData, statusCode, statusMessage),
);

return createdJob.save();
}

Expand Down
4 changes: 2 additions & 2 deletions src/jobs/schemas/job.schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose";
import { ApiHideProperty } from "@nestjs/swagger";
import { Document } from "mongoose";
import { Document, SchemaTypes } from "mongoose";
import { v4 as uuidv4 } from "uuid";
import { OwnableClass } from "src/common/schemas/ownable.schema";

Expand Down Expand Up @@ -101,7 +101,7 @@ export class JobClass extends OwnableClass {
* Contains the dataset archiving results. Initially empty, then provided during update.
*/
@Prop({
type: Object,
type: SchemaTypes.Mixed,
required: true,
default: {},
})
Expand Down
Loading
Loading