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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ DATABASE_URL=postgresql://chainlearn:password@localhost:5432/chainlearn
# ── Redis ──────────────────────────────────────────
REDIS_URL=redis://localhost:6379

# ── CORS ───────────────────────────────────────────
# Comma-separated allow-list of browser origins. Leave unset to use the
# per-environment default (production: https://chainlearn.io,
# development: http://localhost:3000).
# CORS_ORIGINS=https://chainlearn.io,https://app.chainlearn.io

# ── JWT ────────────────────────────────────────────
# OWASP: 256-bit secret = at least 64 characters, must not be a placeholder
JWT_SECRET=replace-this-with-a-random-256-bit-secret-that-is-at-least-64-characters
Expand Down
32 changes: 32 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ const envSchema = z.object({
// Redis
REDIS_URL: z.string().default("redis://localhost:6379"),

// CORS — comma-separated allow-list of browser origins, e.g.
// "https://chainlearn.io,https://app.chainlearn.io". Optional: when unset,
// a per-environment default is used (see `corsOrigins` below). Parsed into
// an array of trimmed, non-empty origin strings.
CORS_ORIGINS: z
.string()
.optional()
.transform((val) =>
val
? val
.split(",")
.map((origin) => origin.trim())
.filter(Boolean)
: undefined,
),

// JWT — OWASP recommends 256 bits (>= 64 chars) and a non-placeholder value.
JWT_SECRET: z
.string()
Expand Down Expand Up @@ -87,6 +103,7 @@ function loadConfig(): Env {
return envSchema.parse({
DATABASE_URL: process.env.DATABASE_URL || "postgresql://chainlearn_test:test_password@localhost:5432/chainlearn_test",
REDIS_URL: process.env.REDIS_URL || "redis://localhost:6379",
CORS_ORIGINS: process.env.CORS_ORIGINS,
JWT_SECRET:
process.env.JWT_SECRET || "test-secret-key-that-is-at-least-sixty-four-characters-long-for-tests",
STELLAR_HORIZON_URL: process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org",
Expand Down Expand Up @@ -121,3 +138,18 @@ function ensureConfig(): Env {
// Eagerly load config at module import time to preserve type safety
// (test-mode fallback is handled in loadConfig())
export const config: Env = ensureConfig();

/**
* Resolved CORS allow-list passed to @fastify/cors.
*
* 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.
*/
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"];
73 changes: 68 additions & 5 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import crypto from "node:crypto";
import type { FastifyRequest, FastifyReply } from "fastify";
import { authService } from "./auth.service.js";
import {
issueRefreshToken,
rotateRefreshToken,
revokeRefreshToken,
} from "./refresh-token.service.js";
import { revokeToken } from "../../middleware/auth.js";
import type { AuthenticatedRequest } from "../../middleware/auth.js";
import type { ChallengeBody, VerifyBody } from "./auth.types.js";
import { logger } from "../../utils/logger.js";
import type {
ChallengeBody,
VerifyBody,
RefreshBody,
LogoutBody,
} from "./auth.types.js";

const JWT_TTL_SECONDS = 24 * 60 * 60; // must match the expiresIn below
const ACCESS_TOKEN_EXPIRES_IN = "24h";

export class AuthController {
/**
Expand All @@ -27,7 +38,7 @@ export class AuthController {

/**
* POST /api/auth/verify
* Verify the signed challenge and return a JWT.
* Verify the signed challenge and return an access token + refresh token.
*/
async verify(
request: FastifyRequest<{ Body: VerifyBody }>,
Expand All @@ -48,25 +59,69 @@ export class AuthController {
stellarAddress: authResult.user.stellarAddress,
jti: crypto.randomUUID(),
},
{ expiresIn: "24h" }
{ expiresIn: ACCESS_TOKEN_EXPIRES_IN }
);

// Issue a refresh token alongside it. Starts its own rotation family so
// this login can be revoked independently of the user's other sessions.
const refresh = await issueRefreshToken(
authResult.user.id,
authResult.user.stellarAddress
);

reply.send({
success: true,
data: {
token,
refreshToken: refresh.token,
user: authResult.user,
},
});
}

/**
* POST /api/auth/refresh
* Exchange a valid refresh token for a new access token. The refresh token
* is single-use: it is invalidated here and a new one is returned in its
* place (rotation). Replaying an already-used token burns the whole family.
*/
async refresh(
request: FastifyRequest<{ Body: RefreshBody }>,
reply: FastifyReply
): Promise<void> {
const { refreshToken } = request.body;

const { record, next } = await rotateRefreshToken(refreshToken);

const token = request.server.jwt.sign(
{
sub: record.userId,
stellarAddress: record.stellarAddress,
jti: crypto.randomUUID(),
},
{ expiresIn: ACCESS_TOKEN_EXPIRES_IN }
);

reply.send({
success: true,
data: {
token,
refreshToken: next.token,
},
});
}

/**
* POST /api/auth/logout
* Revoke the caller's current JWT by adding its jti to the Redis denylist.
* The entry expires automatically when the token would have expired anyway.
*
* If the client also sends its `refreshToken`, that token's rotation family
* is revoked too, so this device's session cannot be resumed via refresh.
* Other devices (separate families) are unaffected.
*/
async logout(
request: FastifyRequest,
request: FastifyRequest<{ Body: LogoutBody }>,
reply: FastifyReply
): Promise<void> {
const decoded = request.user as {
Expand All @@ -80,6 +135,14 @@ export class AuthController {
await revokeToken(decoded.jti, remainingTtl);
}

const refreshToken = request.body?.refreshToken;
if (refreshToken) {
// Best-effort: a failure here must not fail the logout itself.
await revokeRefreshToken(refreshToken).catch((err) =>
logger.warn({ err }, "logout: failed to revoke refresh token")
);
}

reply.send({ success: true, data: { message: "Logged out successfully" } });
}
}
Expand Down
54 changes: 50 additions & 4 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@ import { authController } from "./auth.controller.js";
import { validate } from "../../middleware/validation.js";
import { authGuard } from "../../middleware/auth.js";
import { authRateLimit } from "../../middleware/rate-limit.js";
import { challengeSchema, verifySchema } from "./auth.types.js";
import {
challengeSchema,
verifySchema,
refreshSchema,
logoutSchema,
} from "./auth.types.js";

export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post<{ Body: import("./auth.types.js").ChallengeBody }>(
Expand Down Expand Up @@ -48,14 +53,55 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
(request, reply) => authController.verify(request, reply)
);

app.post(
app.post<{ Body: import("./auth.types.js").RefreshBody }>(
"/refresh",
{
config: { rateLimit: authRateLimit },
preHandler: [validate({ body: refreshSchema })],
schema: {
description:
"Exchange a refresh token for a new access token. The refresh token is single-use and is rotated — a new one is returned in the response.",
tags: ["auth"],
body: {
type: "object",
required: ["refreshToken"],
properties: {
refreshToken: { type: "string", maxLength: 512 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
data: {
type: "object",
properties: {
token: { type: "string" },
refreshToken: { type: "string" },
},
},
},
},
},
} as FastifySchema,
},
(request, reply) => authController.refresh(request, reply)
);

app.post<{ Body: import("./auth.types.js").LogoutBody }>(
"/logout",
{
preHandler: [authGuard],
preHandler: [authGuard, validate({ body: logoutSchema })],
schema: {
description: "Revoke the caller's JWT — the token is immediately invalidated server-side",
description: "Revoke the caller's JWT — the token is immediately invalidated server-side. Optionally pass the refresh token to also revoke this session's refresh-token family.",
tags: ["auth"],
security: [{ bearerAuth: [] }],
// No `body` JSON schema here on purpose: a bare `{ type: "object" }`
// makes Fastify 400 a bodyless logout ("body must be object"), which
// would break the header-only logout contract. The optional
// `logoutSchema` in the validate() preHandler covers the body when
// one is sent.
response: {
200: {
type: "object",
Expand Down
34 changes: 34 additions & 0 deletions src/modules/auth/auth.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,31 @@ export const verifySchema = z.object({
.max(10_000, "Signed challenge exceeds maximum allowed length"),
});

export const refreshSchema = z.object({
refreshToken: z
.string()
.min(1, "refreshToken is required")
.max(512, "refreshToken exceeds maximum allowed length"),
});

// Body is optional — logout works with just the Authorization header. When a
// body is sent, `refreshToken` is the only accepted field.
export const logoutSchema = z
.object({
refreshToken: z
.string()
.min(1)
.max(512, "refreshToken exceeds maximum allowed length")
.optional(),
})
.optional();

// ─── Types ──────────────────────────────────────────────────────────────────

export type ChallengeBody = z.infer<typeof challengeSchema>;
export type VerifyBody = z.infer<typeof verifySchema>;
export type RefreshBody = z.infer<typeof refreshSchema>;
export type LogoutBody = z.infer<typeof logoutSchema>;

export interface ChallengeResponse {
challenge: string;
Expand All @@ -41,3 +62,16 @@ export interface AuthResponse {
isNewUser: boolean;
};
}

export interface VerifyResponseData {
/** Short-lived (24h) access token. */
token: string;
/** Long-lived (7d) single-use refresh token — rotated on every use. */
refreshToken: string;
user: AuthResponse["user"];
}

export interface RefreshResponseData {
token: string;
refreshToken: string;
}
Loading