diff --git a/.env.example b/.env.example index f2edbc1..ee5c4ad 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/config/index.ts b/src/config/index.ts index 7d70905..bbf12cb 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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() @@ -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", @@ -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"]; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts index ccf79c0..df6bb2a 100644 --- a/src/modules/auth/auth.controller.ts +++ b/src/modules/auth/auth.controller.ts @@ -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 { /** @@ -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 }>, @@ -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 { + 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 { const decoded = request.user as { @@ -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" } }); } } diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts index d9932b1..9f7a364 100644 --- a/src/modules/auth/auth.routes.ts +++ b/src/modules/auth/auth.routes.ts @@ -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 { app.post<{ Body: import("./auth.types.js").ChallengeBody }>( @@ -48,14 +53,55 @@ export async function authRoutes(app: FastifyInstance): Promise { (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", diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts index 8288ca2..b69fcc3 100644 --- a/src/modules/auth/auth.types.ts +++ b/src/modules/auth/auth.types.ts @@ -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; export type VerifyBody = z.infer; +export type RefreshBody = z.infer; +export type LogoutBody = z.infer; export interface ChallengeResponse { challenge: string; @@ -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; +} diff --git a/src/modules/auth/refresh-token.service.ts b/src/modules/auth/refresh-token.service.ts new file mode 100644 index 0000000..ec53b86 --- /dev/null +++ b/src/modules/auth/refresh-token.service.ts @@ -0,0 +1,191 @@ +import crypto from "node:crypto"; +import { redis } from "../../config/redis.js"; +import { logger } from "../../utils/logger.js"; +import { UnauthorizedError } from "../../utils/errors.js"; + +/** + * Refresh-token issuance, rotation, single-use enforcement, and + * reuse-detection for the SEP-10 auth flow (#275). + * + * Storage is Redis, matching the rest of the auth module: SEP-10 challenges + * (auth.service.ts) and the JWT denylist (middleware/auth.ts) already keep + * their state in Redis with a TTL and atomic single-use semantics, so a + * refresh token — short-lived, single-use, revocable — belongs there too. + * No Postgres table is involved. + * + * Raw refresh tokens are never persisted. Redis keys carry a SHA-256 of the + * token, the same "store only an opaque derived value" approach the SEP-10 + * layer uses; a leaked Redis snapshot yields no usable token. + */ + +// Access tokens stay at 24h (auth.controller.ts JWT_TTL_SECONDS). Refresh +// tokens live 7 days — long enough to survive a week of inactivity, short +// enough to bound the blast radius of a leaked token. Expressed as a +// module constant to match CHALLENGE_TTL_SECONDS / JWT_TTL_SECONDS; the +// config schema currently carries no expiry values. +export const REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60; + +// hash -> RefreshTokenRecord JSON. Deleted on first use (rotation). +const ACTIVE_PREFIX = "auth:refresh:active:"; +// hash -> familyId. Written when a token is rotated; a later presentation of +// the same token hits this and is treated as a replay. +const CONSUMED_PREFIX = "auth:refresh:consumed:"; +// familyId -> "1". Set when a family is burned (reuse detected, or logout). +const FAMILY_REVOKED_PREFIX = "auth:refresh:family-revoked:"; + +export interface RefreshTokenRecord { + userId: string; + stellarAddress: string; + /** Shared by every token in one rotation lineage. Revoked as a unit. */ + familyId: string; + issuedAt: number; + expiresAt: number; +} + +export interface IssuedRefreshToken { + /** Opaque token string — returned to the client once, never stored raw. */ + token: string; + familyId: string; + record: RefreshTokenRecord; +} + +function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +function nowSeconds(): number { + return Math.floor(Date.now() / 1000); +} + +async function mintInFamily( + userId: string, + stellarAddress: string, + familyId: string, +): Promise { + const token = crypto.randomBytes(32).toString("base64url"); + const issuedAt = nowSeconds(); + const record: RefreshTokenRecord = { + userId, + stellarAddress, + familyId, + issuedAt, + expiresAt: issuedAt + REFRESH_TOKEN_TTL_SECONDS, + }; + await redis.setex( + `${ACTIVE_PREFIX}${hashToken(token)}`, + REFRESH_TOKEN_TTL_SECONDS, + JSON.stringify(record), + ); + return { token, familyId, record }; +} + +/** + * Issue a fresh refresh token that starts its own rotation family. Called + * alongside the access token on a successful SEP-10 verify. + */ +export async function issueRefreshToken( + userId: string, + stellarAddress: string, +): Promise { + return mintInFamily(userId, stellarAddress, crypto.randomUUID()); +} + +async function isFamilyRevoked(familyId: string): Promise { + return (await redis.get(`${FAMILY_REVOKED_PREFIX}${familyId}`)) !== null; +} + +/** + * Burn an entire rotation family. Every outstanding token in the lineage + * stops working immediately; independent families (other devices/sessions) + * are untouched. + */ +export async function revokeRefreshFamily( + familyId: string, + reason: string, +): Promise { + await redis.setex( + `${FAMILY_REVOKED_PREFIX}${familyId}`, + REFRESH_TOKEN_TTL_SECONDS, + "1", + ); + logger.info({ familyId, reason }, "Refresh token family revoked"); +} + +/** + * Validate and atomically consume `token`, returning its stored record plus + * a freshly minted replacement token in the same family. + * + * Single-use: the active key is removed with GETDEL, so two concurrent + * refreshes cannot both succeed and a second call with the same token + * finds nothing. A token that was already rotated once (present in the + * consumed set) is a replay — the standard signal of a stolen token — and + * trips revocation of the whole family before rejecting. + */ +export async function rotateRefreshToken(token: string): Promise<{ + record: RefreshTokenRecord; + next: IssuedRefreshToken; +}> { + const hash = hashToken(token); + + const raw = await redis.getdel(`${ACTIVE_PREFIX}${hash}`); + if (!raw) { + const familyId = await redis.get(`${CONSUMED_PREFIX}${hash}`); + if (familyId) { + await revokeRefreshFamily(familyId, "refresh-token-reuse"); + logger.warn( + { familyId }, + "Refresh token reuse detected — revoking family", + ); + } + throw new UnauthorizedError("Invalid or expired refresh token"); + } + + let record: RefreshTokenRecord; + try { + record = JSON.parse(raw) as RefreshTokenRecord; + } catch { + throw new UnauthorizedError("Invalid or expired refresh token"); + } + + if (await isFamilyRevoked(record.familyId)) { + throw new UnauthorizedError("Refresh token has been revoked"); + } + + if (record.expiresAt <= nowSeconds()) { + throw new UnauthorizedError("Invalid or expired refresh token"); + } + + // Remember this token as spent so a future replay is caught above. Kept + // for a full TTL — long enough to still be around if a stolen copy is + // presented late in the window. + await redis.setex( + `${CONSUMED_PREFIX}${hash}`, + REFRESH_TOKEN_TTL_SECONDS, + record.familyId, + ); + + const next = await mintInFamily( + record.userId, + record.stellarAddress, + record.familyId, + ); + return { record, next }; +} + +/** + * Best-effort revoke of a single refresh token and its family. Used on + * logout, where the client hands back the refresh token it holds. Silent if + * the token is unknown — logout must not fail because a token was already + * gone. + */ +export async function revokeRefreshToken(token: string): Promise { + const hash = hashToken(token); + const raw = await redis.getdel(`${ACTIVE_PREFIX}${hash}`); + if (!raw) return; + try { + const record = JSON.parse(raw) as RefreshTokenRecord; + await revokeRefreshFamily(record.familyId, "logout"); + } catch { + // Corrupt record — nothing more we can do, and logout still succeeds. + } +} diff --git a/src/server.ts b/src/server.ts index a21f653..c0ded39 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,7 @@ import rateLimit from "@fastify/rate-limit"; import swagger from "@fastify/swagger"; import swaggerUi from "@fastify/swagger-ui"; import { sql } from "drizzle-orm"; -import { config } from "./config/index.js"; +import { config, corsOrigins } from "./config/index.js"; import { logger } from "./utils/logger.js"; import { registry, setupInfraMetrics } from "./metrics/index.js"; import { registerMetricsHook } from "./metrics/fastify-hook.js"; @@ -172,11 +172,12 @@ async function buildApp() { // CSRF-safe (cross-site requests can't set custom headers). `credentials: // true` only matters if auth ever moves to cookies — if it does, add a CSRF // token (e.g. @fastify/csrf-protection) and restrict the origin list. + // + // `corsOrigins` comes from config: CORS_ORIGINS (comma-separated) when set, + // otherwise the per-environment default (chainlearn.io in production, + // localhost:3000 elsewhere) — see src/config/index.ts. await app.register(cors, { - origin: - config.NODE_ENV === "production" - ? ["https://chainlearn.io"] - : ["http://localhost:3000"], + origin: corsOrigins, credentials: true, }); diff --git a/tests/unit/auth/auth.controller.test.ts b/tests/unit/auth/auth.controller.test.ts new file mode 100644 index 0000000..b628c96 --- /dev/null +++ b/tests/unit/auth/auth.controller.test.ts @@ -0,0 +1,161 @@ +/** + * #275 — controller wiring for the refresh-token flow. + * + * Verifies auth.controller.ts hands the pieces to the right collaborators: + * verify() issues a refresh token beside the access token, refresh() rotates, + * and logout() revokes the refresh family only when the client sends the + * token. The rotation logic itself is covered in refresh-token.service.test.ts. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../../../src/modules/auth/auth.service.js", () => ({ + authService: { verifyChallenge: vi.fn() }, +})); + +vi.mock("../../../src/modules/auth/refresh-token.service.js", () => ({ + issueRefreshToken: vi.fn(), + rotateRefreshToken: vi.fn(), + revokeRefreshToken: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/middleware/auth.js", () => ({ + revokeToken: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { authController } from "../../../src/modules/auth/auth.controller.js"; +import { authService } from "../../../src/modules/auth/auth.service.js"; +import { + issueRefreshToken, + rotateRefreshToken, + revokeRefreshToken, +} from "../../../src/modules/auth/refresh-token.service.js"; +import { revokeToken } from "../../../src/middleware/auth.js"; + +const USER = { + id: "22222222-2222-4222-8222-222222222222", + stellarAddress: "GCTRLTEST000000000000000000000000000000000000000000000", + displayName: null, + isNewUser: false, +}; + +// The controller handlers are narrowly typed (FastifyRequest<{ Body: ... }>); +// these fakes carry only what each handler touches, cast through `any` the +// same way the other service/controller unit tests in this repo do. +/* eslint-disable @typescript-eslint/no-explicit-any */ +function fakeReply(): any { + return { send: vi.fn() }; +} + +function fakeRequest(overrides: Record = {}): any { + return { + server: { jwt: { sign: vi.fn().mockReturnValue("signed.jwt.token") } }, + ...overrides, + }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +describe("AuthController — refresh flow (#275)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("verify() returns an access token AND a refresh token", async () => { + vi.mocked(authService.verifyChallenge).mockResolvedValue({ + token: "", + user: USER, + }); + vi.mocked(issueRefreshToken).mockResolvedValue({ + token: "refresh-token-abc", + familyId: "fam-1", + record: { + userId: USER.id, + stellarAddress: USER.stellarAddress, + familyId: "fam-1", + issuedAt: 0, + expiresAt: 0, + }, + }); + + const reply = fakeReply(); + await authController.verify( + fakeRequest({ + body: { + stellarAddress: USER.stellarAddress, + challengeId: "cid", + signedChallenge: "sig", + }, + }), + reply, + ); + + expect(issueRefreshToken).toHaveBeenCalledWith(USER.id, USER.stellarAddress); + const payload = reply.send.mock.calls[0][0]; + expect(payload.data.token).toBe("signed.jwt.token"); + expect(payload.data.refreshToken).toBe("refresh-token-abc"); + expect(payload.data.user).toEqual(USER); + }); + + it("refresh() rotates: new access token from the record, rotated refresh token echoed back", async () => { + vi.mocked(rotateRefreshToken).mockResolvedValue({ + record: { + userId: USER.id, + stellarAddress: USER.stellarAddress, + familyId: "fam-1", + issuedAt: 0, + expiresAt: 0, + }, + next: { + token: "refresh-token-2", + familyId: "fam-1", + record: { + userId: USER.id, + stellarAddress: USER.stellarAddress, + familyId: "fam-1", + issuedAt: 0, + expiresAt: 0, + }, + }, + }); + + const request = fakeRequest({ body: { refreshToken: "refresh-token-1" } }); + const reply = fakeReply(); + await authController.refresh(request, reply); + + expect(rotateRefreshToken).toHaveBeenCalledWith("refresh-token-1"); + expect(request.server.jwt.sign).toHaveBeenCalledWith( + expect.objectContaining({ + sub: USER.id, + stellarAddress: USER.stellarAddress, + }), + expect.objectContaining({ expiresIn: "24h" }), + ); + const payload = reply.send.mock.calls[0][0]; + expect(payload.data.token).toBe("signed.jwt.token"); + expect(payload.data.refreshToken).toBe("refresh-token-2"); + }); + + it("logout() revokes the refresh family when the client sends the token", async () => { + const request = fakeRequest({ + user: { jti: "jti-1", exp: Math.floor(Date.now() / 1000) + 3600 }, + body: { refreshToken: "refresh-token-1" }, + }); + await authController.logout(request, fakeReply()); + + expect(revokeToken).toHaveBeenCalledWith("jti-1", expect.any(Number)); + expect(revokeRefreshToken).toHaveBeenCalledWith("refresh-token-1"); + }); + + it("logout() with no body still revokes the access token and does not touch refresh state", async () => { + const request = fakeRequest({ + user: { jti: "jti-1", exp: Math.floor(Date.now() / 1000) + 3600 }, + }); + await authController.logout(request, fakeReply()); + + expect(revokeToken).toHaveBeenCalledWith("jti-1", expect.any(Number)); + expect(revokeRefreshToken).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/auth/refresh-route.test.ts b/tests/unit/auth/refresh-route.test.ts new file mode 100644 index 0000000..a9f1758 --- /dev/null +++ b/tests/unit/auth/refresh-route.test.ts @@ -0,0 +1,226 @@ +/** + * #275 — HTTP-path coverage for the refresh flow. + * + * buildApp() can't be used here (pre-existing Fastify v5 `logger` vs + * `loggerInstance` bug in src/server.ts breaks every e2e suite), so this + * stands up a minimal Fastify app with the real @fastify/jwt plugin, the + * real error handler and the real authRoutes, and drives it over + * app.inject(). Redis and the SEP-10 verify step are stubbed; everything + * from route → validation → controller → refresh-token.service is real. + * + * The equivalent full-stack assertions are also added to + * tests/e2e/auth.test.ts for when that suite is unblocked. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import Fastify, { type FastifyInstance } from "fastify"; +import fastifyJwt from "@fastify/jwt"; + +const store = new Map(); + +vi.mock("../../../src/config/redis.js", () => ({ + redis: { + setex: vi.fn(async (key: string, _ttl: number, value: string) => { + store.set(key, value); + return "OK"; + }), + get: vi.fn(async (key: string) => store.get(key) ?? null), + getdel: vi.fn(async (key: string) => { + const value = store.get(key) ?? null; + store.delete(key); + return value; + }), + }, +})); + +const { findFirst } = vi.hoisted(() => ({ findFirst: vi.fn() })); +vi.mock("../../../src/config/database.js", () => ({ + db: { query: { users: { findFirst } } }, +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock("../../../src/modules/auth/auth.service.js", () => ({ + authService: { verifyChallenge: vi.fn() }, +})); + +import { authRoutes } from "../../../src/modules/auth/auth.routes.js"; +import { registerErrorHandler } from "../../../src/middleware/error-handler.js"; +import { authService } from "../../../src/modules/auth/auth.service.js"; + +const USER = { + id: "33333333-3333-4333-8333-333333333333", + // 56-char, G-prefixed — satisfies verifySchema's shape checks. + stellarAddress: "G" + "A".repeat(55), + displayName: null, + isNewUser: false, +}; + +const VERIFY_BODY = { + stellarAddress: USER.stellarAddress, + challengeId: "b2f6c271-11a3-4b92-b60d-8848db490a22", + signedChallenge: "signed-challenge-envelope", +}; + +async function buildTestApp(): Promise { + const app = Fastify({ logger: false }); + await app.register(fastifyJwt, { + secret: "test-secret-key-that-is-at-least-64-characters-long-for-testing-only", + sign: { expiresIn: "24h" }, + }); + registerErrorHandler(app); + await app.register(authRoutes, { prefix: "/api/v1/auth" }); + await app.ready(); + return app; +} + +describe("POST /api/v1/auth/refresh (#275)", () => { + let app: FastifyInstance; + + beforeEach(async () => { + store.clear(); + vi.clearAllMocks(); + vi.mocked(authService.verifyChallenge).mockResolvedValue({ + token: "", + user: USER, + }); + // authGuard (on /logout) re-fetches the user row. + findFirst.mockResolvedValue({ + id: USER.id, + stellarAddress: USER.stellarAddress, + deletedAt: null, + bannedAt: null, + }); + app = await buildTestApp(); + }); + + async function login(): Promise<{ token: string; refreshToken: string }> { + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/verify", + payload: VERIFY_BODY, + }); + expect(res.statusCode).toBe(200); + return JSON.parse(res.payload).data; + } + + it("verify issues an access token and a refresh token together", async () => { + const data = await login(); + expect(typeof data.token).toBe("string"); + expect(data.token).toContain("."); // JWT: header.payload.signature + expect(typeof data.refreshToken).toBe("string"); + expect(data.refreshToken.length).toBeGreaterThan(20); + }); + + it("refresh returns a new access token and rotates the refresh token", async () => { + const first = await login(); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: first.refreshToken }, + }); + + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.payload).data; + expect(typeof data.token).toBe("string"); + expect(data.token).toContain("."); + expect(data.refreshToken).toBeTypeOf("string"); + expect(data.refreshToken).not.toBe(first.refreshToken); + }); + + it("a refresh token is single-use — replay is rejected", async () => { + const first = await login(); + + const ok = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: first.refreshToken }, + }); + expect(ok.statusCode).toBe(200); + + const replay = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: first.refreshToken }, + }); + expect(replay.statusCode).toBe(401); + }); + + it("replaying a rotated token revokes the whole family", async () => { + const first = await login(); + + const rotated = JSON.parse( + ( + await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: first.refreshToken }, + }) + ).payload, + ).data; + + // Attacker replays the stolen original. + await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: first.refreshToken }, + }); + + // The legitimate, never-used rotated token is now dead too. + const afterBurn = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: rotated.refreshToken }, + }); + expect(afterBurn.statusCode).toBe(401); + }); + + it("rejects an unknown refresh token with 401", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken: "not-a-real-token" }, + }); + expect(res.statusCode).toBe(401); + }); + + it("rejects a missing refresh token with 400", async () => { + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: {}, + }); + expect(res.statusCode).toBe(400); + }); + + it("logout still works with only the Authorization header (no body)", async () => { + const { token } = await login(); + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/logout", + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.statusCode).toBe(200); + }); + + it("logout with the refresh token kills that session's refresh family", async () => { + const { token, refreshToken } = await login(); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/auth/logout", + headers: { authorization: `Bearer ${token}` }, + payload: { refreshToken }, + }); + expect(res.statusCode).toBe(200); + + const afterLogout = await app.inject({ + method: "POST", + url: "/api/v1/auth/refresh", + payload: { refreshToken }, + }); + expect(afterLogout.statusCode).toBe(401); + }); +}); diff --git a/tests/unit/auth/refresh-token.service.test.ts b/tests/unit/auth/refresh-token.service.test.ts new file mode 100644 index 0000000..218635d --- /dev/null +++ b/tests/unit/auth/refresh-token.service.test.ts @@ -0,0 +1,178 @@ +/** + * #275 — JWT refresh mechanism (API side). + * + * Exercises issuance, rotation, single-use enforcement and reuse-detection + * in refresh-token.service.ts with an in-memory Redis stub, following the + * same approach as jwt-revocation.test.ts so it runs without live infra. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// ─── In-memory Redis stub ─────────────────────────────────────────────────── + +const store = new Map(); + +vi.mock("../../../src/config/redis.js", () => ({ + redis: { + setex: vi.fn(async (key: string, _ttl: number, value: string) => { + store.set(key, value); + return "OK"; + }), + get: vi.fn(async (key: string) => store.get(key) ?? null), + getdel: vi.fn(async (key: string) => { + const value = store.get(key) ?? null; + store.delete(key); + return value; + }), + }, +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +// ─── Import after mocks are registered ────────────────────────────────────── + +import { + issueRefreshToken, + rotateRefreshToken, + revokeRefreshToken, + REFRESH_TOKEN_TTL_SECONDS, +} from "../../../src/modules/auth/refresh-token.service.js"; +import { UnauthorizedError } from "../../../src/utils/errors.js"; + +const USER_ID = "11111111-1111-4111-8111-111111111111"; +const STELLAR_ADDRESS = "GREFRESHTEST00000000000000000000000000000000000000000"; + +function activeKeys(): string[] { + return [...store.keys()].filter((k) => k.startsWith("auth:refresh:active:")); +} + +describe("refresh token service (#275)", () => { + beforeEach(() => { + store.clear(); + vi.clearAllMocks(); + }); + + it("refresh token expiry is 7 days", () => { + expect(REFRESH_TOKEN_TTL_SECONDS).toBe(7 * 24 * 60 * 60); + expect(REFRESH_TOKEN_TTL_SECONDS).toBe(604_800); + }); + + it("issues an opaque token that is never stored raw", async () => { + const issued = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + + expect(issued.token).toMatch(/^[A-Za-z0-9_-]+$/); + expect(issued.familyId).toMatch(/^[0-9a-f-]{36}$/); + expect(activeKeys()).toHaveLength(1); + // The raw token must not appear anywhere in the Redis keyspace or values. + for (const [k, v] of store) { + expect(k).not.toContain(issued.token); + expect(v).not.toContain(issued.token); + } + }); + + it("rotates a valid token: returns a new token and the record, old token is dead", async () => { + const issued = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + + const { record, next } = await rotateRefreshToken(issued.token); + + expect(record.userId).toBe(USER_ID); + expect(record.stellarAddress).toBe(STELLAR_ADDRESS); + expect(next.token).not.toBe(issued.token); + // Same rotation lineage. + expect(next.familyId).toBe(issued.familyId); + // Exactly one active token remains — the replacement. + expect(activeKeys()).toHaveLength(1); + + // The consumed token can no longer be rotated. + await expect(rotateRefreshToken(issued.token)).rejects.toBeInstanceOf( + UnauthorizedError, + ); + }); + + it("is single-use under concurrency: only one of two racing rotations wins", async () => { + const issued = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + + const results = await Promise.allSettled([ + rotateRefreshToken(issued.token), + rotateRefreshToken(issued.token), + ]); + + const fulfilled = results.filter((r) => r.status === "fulfilled"); + const rejected = results.filter((r) => r.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + }); + + it("detects reuse of an already-rotated token and burns the whole family", async () => { + const issued = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + + // Legitimate rotation → token2. + const { next: gen2 } = await rotateRefreshToken(issued.token); + // Attacker replays the stolen, already-rotated token1. + await expect(rotateRefreshToken(issued.token)).rejects.toBeInstanceOf( + UnauthorizedError, + ); + + // token2 — a valid, never-used token in the same family — is now dead too. + await expect(rotateRefreshToken(gen2.token)).rejects.toThrow(/revoked/i); + }); + + it("rejects an unknown token without revoking anything", async () => { + // A live, unrelated family that must survive. + const survivor = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + + await expect(rotateRefreshToken("not-a-real-token")).rejects.toBeInstanceOf( + UnauthorizedError, + ); + + // Survivor still rotates fine. + await expect(rotateRefreshToken(survivor.token)).resolves.toMatchObject({ + record: { familyId: survivor.familyId }, + }); + }); + + it("rejects a token whose record has passed its expiry", async () => { + const issued = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + + // Age the stored record past expiresAt. + const [key] = activeKeys(); + const rec = JSON.parse(store.get(key)!); + rec.expiresAt = Math.floor(Date.now() / 1000) - 1; + store.set(key, JSON.stringify(rec)); + + await expect(rotateRefreshToken(issued.token)).rejects.toBeInstanceOf( + UnauthorizedError, + ); + }); + + it("revokeRefreshToken (logout) kills the token and its family", async () => { + const issued = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + const { next: gen2 } = await rotateRefreshToken(issued.token); + + await revokeRefreshToken(gen2.token); + + await expect(rotateRefreshToken(gen2.token)).rejects.toBeInstanceOf( + UnauthorizedError, + ); + }); + + it("revokeRefreshToken is a silent no-op for an unknown token", async () => { + await expect(revokeRefreshToken("nope")).resolves.toBeUndefined(); + }); + + it("carries identity across a multi-step rotation chain", async () => { + let current = await issueRefreshToken(USER_ID, STELLAR_ADDRESS); + const familyId = current.familyId; + + for (let i = 0; i < 5; i++) { + const { record, next } = await rotateRefreshToken(current.token); + expect(record.userId).toBe(USER_ID); + expect(record.stellarAddress).toBe(STELLAR_ADDRESS); + expect(next.familyId).toBe(familyId); + current = next; + } + + expect(activeKeys()).toHaveLength(1); + }); +}); diff --git a/src/test/cache.test.ts b/tests/unit/cache.test.ts similarity index 93% rename from src/test/cache.test.ts rename to tests/unit/cache.test.ts index 1f8a16f..6426c19 100644 --- a/src/test/cache.test.ts +++ b/tests/unit/cache.test.ts @@ -1,17 +1,17 @@ import { test, describe, expect, beforeEach, afterEach, vi } from "vitest"; -import { db } from "../config/database.js"; -import { redis } from "../config/redis.js"; -import { courseService } from "../modules/courses/course.service.js"; -import { userService } from "../modules/users/user.service.js"; -import { cacheKey, cacheHits, cacheMisses } from "../cache/index.js"; -import { warmCourseCache } from "../cache/warmer.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { courseService } from "../../src/modules/courses/course.service.js"; +import { userService } from "../../src/modules/users/user.service.js"; +import { cacheKey, cacheHits, cacheMisses } from "../../src/cache/index.js"; +import { warmCourseCache } from "../../src/cache/warmer.js"; import { courses, enrollments, users, quizSubmissions, quizzes, -} from "../database/schema.js"; +} from "../../src/database/schema.js"; import { eq } from "drizzle-orm"; describe("Redis Caching & Invalidation Test Suite", () => { diff --git a/tests/unit/config/cors-origins.test.ts b/tests/unit/config/cors-origins.test.ts new file mode 100644 index 0000000..da9a186 --- /dev/null +++ b/tests/unit/config/cors-origins.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +/** + * #274: CORS_ORIGINS env var → resolved allow-list. + * + * config/index.ts reads process.env once at import time and memoizes the + * result, so each case resets the module registry and re-imports it with a + * fresh env. Only CORS_ORIGINS / NODE_ENV are varied; every other required + * var comes from the test .env the rest of the suite already relies on. + */ +async function loadConfig(env: Record) { + vi.resetModules(); + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + vi.stubEnv(key, ""); + delete process.env[key]; + } else { + vi.stubEnv(key, value); + } + } + return import("../../../src/config/index.js"); +} + +describe("CORS_ORIGINS config (#274)", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("defaults to localhost:3000 when unset outside production (no behavior change)", async () => { + const { config, corsOrigins } = await loadConfig({ + NODE_ENV: "development", + CORS_ORIGINS: undefined, + }); + + expect(config.CORS_ORIGINS).toBeUndefined(); + expect(corsOrigins).toEqual(["http://localhost:3000"]); + }); + + it("defaults to chainlearn.io when unset in production (no behavior change)", async () => { + const { corsOrigins } = await loadConfig({ + NODE_ENV: "production", + CORS_ORIGINS: undefined, + }); + + expect(corsOrigins).toEqual(["https://chainlearn.io"]); + }); + + it("treats an empty CORS_ORIGINS the same as unset", async () => { + const { corsOrigins } = await loadConfig({ + NODE_ENV: "production", + CORS_ORIGINS: " ", + }); + + expect(corsOrigins).toEqual(["https://chainlearn.io"]); + }); + + it("parses a comma-separated list into trimmed origins", async () => { + const { config, corsOrigins } = await loadConfig({ + NODE_ENV: "development", + CORS_ORIGINS: " https://a.example , https://b.example ,,https://c.example ", + }); + + expect(config.CORS_ORIGINS).toEqual([ + "https://a.example", + "https://b.example", + "https://c.example", + ]); + expect(corsOrigins).toEqual([ + "https://a.example", + "https://b.example", + "https://c.example", + ]); + }); + + it("overrides the production default when set", async () => { + const { corsOrigins } = await loadConfig({ + NODE_ENV: "production", + CORS_ORIGINS: "https://app.chainlearn.io", + }); + + expect(corsOrigins).toEqual(["https://app.chainlearn.io"]); + }); +}); diff --git a/src/test/course-cache-and-progress.test.ts b/tests/unit/course-cache-and-progress.test.ts similarity index 87% rename from src/test/course-cache-and-progress.test.ts rename to tests/unit/course-cache-and-progress.test.ts index 8050108..0f2ef74 100644 --- a/src/test/course-cache-and-progress.test.ts +++ b/tests/unit/course-cache-and-progress.test.ts @@ -1,13 +1,13 @@ import { test, describe, expect, beforeEach, afterEach, vi } from "vitest"; -import { db } from "../config/database.js"; -import { redis } from "../config/redis.js"; -import { courseService } from "../modules/courses/course.service.js"; -import { userService } from "../modules/users/user.service.js"; -import { quizService } from "../modules/quizzes/quiz.service.js"; -import { cacheKey, cacheKeyPattern } from "../cache/index.js"; -import { warmCourseCache } from "../cache/warmer.js"; -import { logger } from "../utils/logger.js"; -import { courses, enrollments, users } from "../database/schema.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { courseService } from "../../src/modules/courses/course.service.js"; +import { userService } from "../../src/modules/users/user.service.js"; +import { quizService } from "../../src/modules/quizzes/quiz.service.js"; +import { cacheKey, cacheKeyPattern } from "../../src/cache/index.js"; +import { warmCourseCache } from "../../src/cache/warmer.js"; +import { logger } from "../../src/utils/logger.js"; +import { courses, enrollments, users } from "../../src/database/schema.js"; import { eq } from "drizzle-orm"; describe("Course cache, progress TTL, and placeholder fallback (#146, #148, #149, #150)", () => { diff --git a/src/test/quiz-grading-and-warming.test.ts b/tests/unit/quiz-grading-and-warming.test.ts similarity index 91% rename from src/test/quiz-grading-and-warming.test.ts rename to tests/unit/quiz-grading-and-warming.test.ts index d9be7b1..bcff0cf 100644 --- a/src/test/quiz-grading-and-warming.test.ts +++ b/tests/unit/quiz-grading-and-warming.test.ts @@ -1,18 +1,18 @@ import { test, describe, expect, beforeEach, afterEach, vi } from "vitest"; -import { db } from "../config/database.js"; -import { redis } from "../config/redis.js"; -import { quizService } from "../modules/quizzes/quiz.service.js"; -import { PASSING_PERCENTAGE } from "../modules/quizzes/quiz.types.js"; -import { cacheKey } from "../cache/index.js"; -import { warmCourseCache } from "../cache/warmer.js"; -import { logger } from "../utils/logger.js"; +import { db } from "../../src/config/database.js"; +import { redis } from "../../src/config/redis.js"; +import { quizService } from "../../src/modules/quizzes/quiz.service.js"; +import { PASSING_PERCENTAGE } from "../../src/modules/quizzes/quiz.types.js"; +import { cacheKey } from "../../src/cache/index.js"; +import { warmCourseCache } from "../../src/cache/warmer.js"; +import { logger } from "../../src/utils/logger.js"; import { courses, enrollments, users, quizSubmissions, quizzes, -} from "../database/schema.js"; +} from "../../src/database/schema.js"; import { eq } from "drizzle-orm"; describe("Quiz grading edge cases & cache warming (#143, #144, #145, #147)", () => {