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
4 changes: 3 additions & 1 deletion src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ type AuditEvent =
| "webhook.created"
| "webhook.updated"
| "webhook.deleted"
| "webhook.secret_rotated";
| "webhook.secret_rotated"
| "rate_limit.exceeded"
| "cache.invalidated";

interface AuditFields {
userId?: string;
Expand Down
37 changes: 29 additions & 8 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ const envSchema = z.object({
"JWT_SECRET must be a real secret, not a placeholder"
),

// JWT key rotation — comma-separated list of previous secrets. Tokens
// signed with any of these are still accepted, but new tokens are always
// signed with JWT_SECRET. Set this when rotating the signing key so old
// tokens remain valid during the transition window.
JWT_SECRET_PREVIOUS: z
.string()
.optional()
.transform((val) =>
val
? val
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: undefined,
),

// Stellar
STELLAR_NETWORK: z.enum(["testnet", "mainnet"]).default("testnet"),
STELLAR_HORIZON_URL: z.string().url(),
Expand Down Expand Up @@ -171,17 +187,13 @@ function loadConfig(): Env {
fieldErrors: result.error.flatten().fieldErrors,
},
"Missing env vars in test mode (expected if mocking config)"
// are preserved; only non-critical vars get obviously-fake test defaults.
);
// Every field is passed through from process.env (populated above by
// real environment variables, then .env.test, in that precedence)
// consistently, not just the ones that happened to need a fallback —
// config.NODE_ENV itself was previously dropped this way and silently
// defaulted to "development", which meant logger.ts's test-mode branch
// never actually activated during a test run.
console.warn(
"Missing env vars in test mode (expected if mocking config):",
result.error.flatten().fieldErrors
);
return envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL,
REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379",
Expand Down Expand Up @@ -256,12 +268,21 @@ export const config: Env = ensureConfig();
*
* When CORS_ORIGINS is set it wins outright. Otherwise this falls back to the
* exact per-environment defaults the server used before CORS_ORIGINS existed —
* chainlearn.io in production, localhost:3000 everywhere else — so an unset
* CORS_ORIGINS is a no-op change in behavior.
* chainlearn.io in production, localhost:3000 in development. For test/CI
* environments, CORS_ORIGINS must be explicitly set; a missing value falls
* back to localhost:3000 with a warning.
*/
export const corsOrigins: string[] =
config.CORS_ORIGINS && config.CORS_ORIGINS.length > 0
? config.CORS_ORIGINS
: config.NODE_ENV === "production"
? ["https://chainlearn.io"]
: ["http://localhost:3000"];
: (() => {
if (config.NODE_ENV === "test") {
logger.warn(
"CORS_ORIGINS not set in test/CI environment — defaulting to localhost:3000. " +
"Set CORS_ORIGINS explicitly in staging/CI for stricter control."
);
}
return ["http://localhost:3000"];
})();
3 changes: 2 additions & 1 deletion src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { logger } from "../utils/logger.js";
import { redis } from "../config/redis.js";
import { sessionService } from "../modules/auth/session.service.js";
import { safeEqual } from "../utils/crypto.js";

const JWT_DENYLIST_PREFIX = "jwt:revoked:";

Expand Down Expand Up @@ -38,7 +39,7 @@
*/
export async function authGuard(
request: FastifyRequest,
reply: FastifyReply,

Check warning on line 42 in src/middleware/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

'reply' is defined but never used. Allowed unused args must match /^_/u
): Promise<void> {
let decoded: { sub: string; stellarAddress: string; jti?: string };
try {
Expand Down Expand Up @@ -81,7 +82,7 @@

// Validate that the stellarAddress in the JWT matches the database record
// This provides defense-in-depth against token forgery scenarios
if (decoded.stellarAddress !== user.stellarAddress) {
if (!safeEqual(decoded.stellarAddress, user.stellarAddress)) {
throw new UnauthorizedError("Token stellarAddress mismatch");
}

Expand Down
10 changes: 10 additions & 0 deletions src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import type { FastifyRequest } from "fastify";
import { config } from "../config/index.js";
import type { AuthenticatedRequest } from "./auth.js";
import { auditLog } from "../audit/index.js";

const errorResponseBuilder = (
_request: FastifyRequest,
Expand All @@ -22,6 +23,15 @@
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
onExceeded: (request: FastifyRequest) => {
const authReq = request as AuthenticatedRequest;
auditLog("rate_limit.exceeded", {
ip: request.ip,
userId: authReq.authUser?.id,
url: request.url,
method: request.method,
}).catch(() => {});
},
};
}

Expand Down Expand Up @@ -84,7 +94,7 @@
max: 5,
timeWindow: "1 minute",
keyGenerator: (request: FastifyRequest) => {
const authReq = request as any;

Check warning on line 97 in src/middleware/rate-limit.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

Unexpected any. Specify a different type
return authReq.authUser?.id ?? request.ip;
},
errorResponseBuilder,
Expand Down
25 changes: 20 additions & 5 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { revokeToken } from "../../middleware/auth.js";
import type { AuthenticatedRequest } from "../../middleware/auth.js";
import { sessionService } from "./session.service.js";
import { logger } from "../../utils/logger.js";
import { auditLog } from "../../audit/index.js";
import type {
ChallengeBody,
VerifyBody,
Expand Down Expand Up @@ -49,11 +50,20 @@ export class AuthController {
): Promise<void> {
const { stellarAddress, challengeId, signedChallenge } = request.body;

const authResult = await authService.verifyChallenge(
stellarAddress,
challengeId,
signedChallenge
);
let authResult;
try {
authResult = await authService.verifyChallenge(
stellarAddress,
challengeId,
signedChallenge
);
} catch (err) {
auditLog("auth.login_failed", {
ip: request.ip,
stellarAddress,
}).catch(() => {});
throw err;
}

// Generate JWT — jti enables per-token revocation via the Redis denylist.
const token = request.server.jwt.sign(
Expand All @@ -72,6 +82,11 @@ export class AuthController {
authResult.user.stellarAddress
);

auditLog("auth.login", {
userId: authResult.user.id,
ip: request.ip,
}).catch(() => {});

reply.send({
success: true,
data: {
Expand Down
3 changes: 2 additions & 1 deletion src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RateLimitError, UnauthorizedError } from "../../utils/errors.js";
import { logger } from "../../utils/logger.js";
import { eq } from "drizzle-orm";
import type { ChallengeResponse, AuthResponse } from "./auth.types.js";
import { safeEqual } from "../../utils/crypto.js";
import {
checkAuthLockout,
clearAuthFailures,
Expand Down Expand Up @@ -230,7 +231,7 @@ export class AuthService {
throw new UnauthorizedError("Invalid challenge transaction: missing manageData operation");
}
const submittedNonce = Buffer.from(submittedNonceOp.value).toString("base64");
if (submittedNonce !== issuedNonce) {
if (!safeEqual(submittedNonce, issuedNonce)) {
throw new UnauthorizedError("Challenge transaction does not match the issued challenge");
}

Expand Down
33 changes: 33 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,13 +180,46 @@ async function buildApp() {
await app.register(cors, {
origin: corsOrigins,
credentials: true,
hook: "onRequest",
});

app.addHook("onRequest", (request, _reply, done) => {
const origin = request.headers.origin;
if (origin) {
logger.debug(
{ origin, allowed: corsOrigins.includes(origin) },
"CORS request"
);
}
done();
});

const previousSecrets = config.JWT_SECRET_PREVIOUS ?? [];
await app.register(jwt, {
secret: config.JWT_SECRET,
sign: { expiresIn: "24h" },
});

// Support key rotation: verify tokens against previous secrets when the
// current secret fails. New tokens are always signed with the latest secret.
if (previousSecrets.length > 0) {
const originalVerify = app.jwt.verify.bind(app.jwt);
app.jwt.verify = function (token: string, ...args: any[]) {
try {
return originalVerify(token, ...args);
} catch (err) {
for (const prevSecret of previousSecrets) {
try {
return originalVerify(token, { secret: prevSecret }, ...args.slice(1));
} catch {
// try next
}
}
throw err;
}
};
}

await app.register(rateLimit, rateLimitOptions());

await app.register(multipart, {
Expand Down
4 changes: 4 additions & 0 deletions src/stellar/sequence-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { redis } from "../config/redis.js";
import { stellarClient } from "./client.js";
import { auditLog } from "../audit/index.js";

const KEY_PREFIX = "chainlearn:stellar:sequence:";
// Bounds how long a cached sequence can be trusted before we re-sync with
Expand Down Expand Up @@ -72,6 +73,9 @@ export class SequenceCache {

async invalidate(accountId: string): Promise<void> {
await redis.del(this.key(accountId));
auditLog("cache.invalidated", {
url: `stellar:sequence:${accountId}`,
}).catch(() => {});
}

async resetTo(accountId: string, seq: bigint): Promise<void> {
Expand Down
22 changes: 22 additions & 0 deletions src/utils/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,25 @@ import crypto from "node:crypto";
export function sha256Hash(data: string): string {
return crypto.createHash("sha256").update(data).digest("hex");
}

/**
* Constant-time string comparison to prevent timing side-channel attacks.
* Uses crypto.timingSafeEqual internally; pads the shorter buffer so both
* are the same length (required by the Node.js API).
*/
export function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
// Pad the shorter buffer to match the longer one so timingSafeEqual
// doesn't throw. The length mismatch itself is not secret — an
// attacker learns nothing from it that they couldn't already see by
// comparing the strings directly.
const padded = Buffer.alloc(Math.max(bufA.length, bufB.length));
bufA.copy(padded);
const paddedB = Buffer.alloc(padded.length);
bufB.copy(paddedB);
return crypto.timingSafeEqual(padded, paddedB);
}
return crypto.timingSafeEqual(bufA, bufB);
}
Loading