Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
026114d
fix(auth): pin JWT algorithm allowlist and centralise token lifetimes
ykargeee-bit Oct 12, 2026
2c87a98
fix(auth): pass an explicit expiresIn when EnhancedAuthService issues…
ykargeee-bit Oct 12, 2026
be02f2d
test(auth): assert access-token TTL, expiry enforcement and algorithm…
ykargeee-bit Oct 12, 2026
7168a0e
docs(auth): document access/refresh token lifetimes and the upgrade i…
ykargeee-bit Oct 12, 2026
2aca2e2
test(jest): relocate the portfolio and oracle specs out of test/
ykargeee-bit Oct 12, 2026
7bf9213
test(jest): relocate test/recovery.spec.ts to src/core/auth/
ykargeee-bit Oct 12, 2026
6f45a71
test(jest): relocate and repair test/email-linking.spec.ts
ykargeee-bit Oct 12, 2026
7c40dd0
test(jest): relocate and repair test/wallet-auth.spec.ts
ykargeee-bit Oct 12, 2026
ac43354
test(auth): drop the dead test/enhanced-auth.service.spec.ts and cove…
ykargeee-bit Oct 12, 2026
20e4f3a
test(jest): rename test/oracle-e2e.spec.ts to the .e2e-spec.ts conven…
ykargeee-bit Oct 12, 2026
49bf40a
test(jest): guard against unit specs that no Jest project will run
ykargeee-bit Oct 12, 2026
265c9d7
fix(auth,tests): type the JWT config against JwtModuleOptions and use…
ykargeee-bit Oct 12, 2026
7053ef1
test(email-linking): type the repository mocks so the create() assert…
ykargeee-bit Oct 12, 2026
179f277
test(jest): drop test/portfolio/portfolio.service.spec.ts rather than…
ykargeee-bit Oct 12, 2026
55baec6
fix(file-upload): enforce the MIME allowlist in the handler, not a de…
ykargeee-bit Oct 12, 2026
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,32 @@ The backend supports two authentication methods:

All authentication endpoints use JWT tokens for session management with bcrypt password hashing for traditional auth.

#### Token lifetimes and algorithm

Token lifetimes and the signing algorithm are defined once, in
[`src/config/jwt.config.ts`](src/config/jwt.config.ts), and referenced by both
`AuthService` and `EnhancedAuthService`:

| Token | Lifetime | Constant |
| --- | --- | --- |
| Access token | 15 minutes | `ACCESS_TOKEN_TTL` |
| Refresh token | 7 days | `REFRESH_TOKEN_TTL_MS` |

Tokens are signed with `HS256` and verification is restricted to an explicit
`algorithms: ['HS256']` allowlist; a token presented under any other algorithm is
rejected rather than the verifier choosing for itself.

Clients must treat an expired access token as a normal condition and exchange it
for a new one via the refresh endpoint - a 15-minute access token means an
unattended client will need to refresh roughly every quarter hour.

> **Upgrade note.** `EnhancedAuthService` previously issued access tokens via
> `jwtService.sign(payload)` with no options, so the lifetime was whatever the
> `JwtModule` default happened to be. Sessions issued before this change keep
> whatever lifetime they were minted with until they expire; only newly issued
> tokens are guaranteed to carry a 15-minute `exp`. The refresh token lifetime
> is unchanged.

### Job Control API

Fine-grained control over compute jobs with role-based access control:
Expand Down
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
// Unit specs are colocated with the code they cover, so `rootDir` is `src`.
//
// NOTE: because rootDir is `src`, a `*.spec.ts` written anywhere else in the
// repository matches `testRegex` below but is *never executed* - `npm test`
// still passes, which makes the gap invisible. Tests that need the whole app
// (supertest, TypeOrmModule.forRoot, dropSchema) belong in `test/` and must
// use the `.e2e-spec.ts` suffix so that test/jest-e2e.json picks them up.
// src/config/jest-project-coverage.spec.ts enforces both halves of this.
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { Wallet } from "ethers";
import { PayloadSigningService } from "../../src/blockchain/oracle/services/payload-signing.service";
import { HDNodeWallet } from "ethers";
import { PayloadSigningService } from "./payload-signing.service";

describe("PayloadSigningService", () => {
let service: PayloadSigningService;
let testWallet: Wallet;
let testWallet: HDNodeWallet;
let configService: ConfigService;

beforeEach(async () => {
// Create a test wallet
testWallet = Wallet.createRandom();
testWallet = HDNodeWallet.createRandom();

// Mock ConfigService
const mockConfigService = {
Expand Down Expand Up @@ -207,7 +207,7 @@ describe("PayloadSigningService", () => {
payload,
);

const differentWallet = Wallet.createRandom();
const differentWallet = HDNodeWallet.createRandom();

const isValid = service.verifySignature(
signature,
Expand Down
74 changes: 74 additions & 0 deletions src/config/jest-project-coverage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as fs from "fs";
import * as path from "path";

/**
* Guards the invariant behind the jest spec relocation: every `*.spec.ts` in
* the repository is reachable by one of the configured Jest projects.
*
* `jest.config.js` pins `rootDir: 'src'`, so a unit spec written anywhere
* else matches `testRegex` but is silently never executed. `npm test` passes,
* the test count looks healthy, and the coverage gap is invisible. Five
* specs were sitting in `test/` in exactly that state.
*
* This fails if a `*.spec.ts` is ever added outside `src/` without also
* matching the e2e project's `testRegex`.
*/

const REPO_ROOT = path.resolve(__dirname, "..", "..");

/* eslint-disable @typescript-eslint/no-var-requires */
const UNIT_CONFIG = require(path.join(REPO_ROOT, "jest.config.js"));
const E2E_CONFIG = require(path.join(REPO_ROOT, "test", "jest-e2e.json"));

const IGNORED_DIRECTORIES = new Set([
"node_modules",
"dist",
"coverage",
".git",
]);

const UNIT_ROOT = path.resolve(REPO_ROOT, UNIT_CONFIG.rootDir);
const UNIT_TEST_REGEX = new RegExp(UNIT_CONFIG.testRegex);
const E2E_TEST_REGEX = new RegExp(E2E_CONFIG.testRegex);

function collectSpecs(dir: string, found: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (IGNORED_DIRECTORIES.has(entry.name)) continue;
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectSpecs(entryPath, found);
} else if (entry.isFile() && entry.name.endsWith(".spec.ts")) {
found.push(entryPath);
}
}
return found;
}

describe("jest project coverage", () => {
it("pins the unit suite rootDir to src, so specs are colocated with the code", () => {
// Stated explicitly because it is the reason a spec outside src/ can be
// silently skipped: the rootDir, not the testRegex, decides what runs.
expect(path.relative(REPO_ROOT, UNIT_ROOT)).toBe("src");
});

it("only finds *.spec.ts files that a configured Jest project will run", () => {
const orphaned = collectSpecs(REPO_ROOT).filter((file) => {
if (UNIT_TEST_REGEX.test(file) && file.startsWith(UNIT_ROOT)) return false;
// Anything else has to be picked up by the e2e project instead.
return !E2E_TEST_REGEX.test(file);
});

expect(
orphaned.map((file) => path.relative(REPO_ROOT, file)),
).toEqual([]);
});

it("has no *.spec.ts left under test/ (e2e specs use the .e2e-spec.ts suffix)", () => {
const testDir = path.join(REPO_ROOT, "test");
const unitSpecs = collectSpecs(testDir).filter(
(file) => !E2E_TEST_REGEX.test(file),
);

expect(unitSpecs).toEqual([]);
});
});
120 changes: 120 additions & 0 deletions src/config/jwt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { JwtModuleOptions } from "@nestjs/jwt";

/**
* Deriving the duration/algorithm types from `JwtModuleOptions` rather than
* re-declaring them as `string`/`string[]` keeps this file honest: `jsonwebtoken`
* types `expiresIn` as `number | ms.StringValue` (a template-literal union) and
* `algorithms` as `Algorithm[]`, so a bare `string` is not assignable and the
* compiler catches a malformed TTL at the definition rather than at runtime.
*/
type TokenLifetime = NonNullable<
NonNullable<JwtModuleOptions["signOptions"]>["expiresIn"]
>;
type VerifyAlgorithms = NonNullable<
NonNullable<JwtModuleOptions["verifyOptions"]>["algorithms"]
>;

/**
* Single source of truth for JWT lifetimes and algorithm policy.
*
* Previously the access-token lifetime was written out as the string literal
* `"15m"` in two different services (`AuthService.register`/`AuthService.login`
* passed it explicitly, `EnhancedAuthService.generateTokens` relied on the
* `JwtModule` default). That is exactly the kind of drift that lets a token end
* up with no `exp` claim at all - and a token with no `exp` never expires, so a
* leak cannot be contained and logout cannot meaningfully revoke it. Keeping the
* TTL here means both services reference the same value and the module default
* cannot silently change what the services think they are issuing.
*/

/**
* Access-token lifetime, as a `ms`-style string understood by `jsonwebtoken`.
* Shared by `AuthService` and `EnhancedAuthService`.
*/
export const ACCESS_TOKEN_TTL: TokenLifetime = "15m";

/**
* `ACCESS_TOKEN_TTL` expressed in seconds, for assertions and for places that
* need a numeric value (e.g. `exp - iat` checks).
*/
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;

/**
* Refresh-token lifetime. Deliberately unchanged - refresh tokens are
* long-lived by design and are revoked server-side via the `refresh_tokens`
* table rather than by expiry alone.
*/
export const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;

/** `REFRESH_TOKEN_TTL_MS` as a `jsonwebtoken` duration string. */
export const REFRESH_TOKEN_TTL: TokenLifetime = "7d";

/**
* Lifetime for the long-lived tokens issued by `DashboardModule` (websocket
* gateway tickets) and `NotificationModule` (consumer tokens). These are
* separate issuers from the 15-minute access-token issuer in `AuthModule`, and
* their TTLs are unchanged.
*/
export const LONG_LIVED_TOKEN_TTL: TokenLifetime = "24h";

/**
* Algorithm used to sign new tokens. Pinned explicitly rather than relying on
* `jsonwebtoken`'s default so that changing the default can never silently
* change what we issue.
*/
export const JWT_SIGNING_ALGORITHM = "HS256";

/**
* Algorithms accepted during verification. Pinning an allowlist means a token
* presented with some other algorithm is rejected outright instead of the
* verifier deciding for itself.
*/
export const JWT_VERIFY_ALGORITHMS: VerifyAlgorithms = [
JWT_SIGNING_ALGORITHM,
];

export interface JwtIssuerConfig {
/** Signing/verification secret. */
secret: string;
/** Default lifetime applied when a caller does not pass its own `expiresIn`. */
expiresIn: TokenLifetime;
}

/**
* Build the `JwtModule` options for an issuer.
*
* Both the signing and the verification side are pinned:
*
* - `signOptions.algorithm` - so we always issue `HS256`, never whatever the
* library defaults to.
* - `verifyOptions.algorithms` - an explicit allowlist. `jsonwebtoken.verify`
* throws `JsonWebTokenError: invalid algorithm` for anything outside it,
* which blocks algorithm-substitution attempts.
*
* Note that `algorithms` belongs on `verifyOptions` (it is a *verification*
* concern); `JwtModuleOptions` has no top-level `algorithms` key, so putting
* it at the top level of the module options would be silently ignored.
*/
export function buildJwtModuleOptions(config: JwtIssuerConfig): JwtModuleOptions {
return {
secret: config.secret,
signOptions: {
expiresIn: config.expiresIn,
algorithm: JWT_SIGNING_ALGORITHM,
},
// Copied so callers cannot mutate the shared allowlist.
verifyOptions: {
algorithms: [...JWT_VERIFY_ALGORITHMS],
},
};
}

/**
* Options for the access-token issuer (`AuthModule`), which backs both
* `AuthService` and `EnhancedAuthService`.
*/
export function buildAccessTokenJwtModuleOptions(
secret: string,
): JwtModuleOptions {
return buildJwtModuleOptions({ secret, expiresIn: ACCESS_TOKEN_TTL });
}
Loading
Loading