diff --git a/.env.example b/.env.example index 60299347..2a43f156 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,10 @@ JWT_AUDIENCE=brandblitz-client GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret WEB_URL=http://localhost:3000 +GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/callback/google +GOOGLE_OAUTH_PKCE_TTL_SECONDS=300 +# Defaults to strict-origin-when-cross-origin; set to no-referrer for stricter deployments. +REFERRER_POLICY=strict-origin-when-cross-origin # NextAuth configuration NEXTAUTH_SECRET=same-as-jwt-secret-for-nextauth diff --git a/apps/api/.env.test b/apps/api/.env.test index 7a049cdc..a09a0d6b 100644 --- a/apps/api/.env.test +++ b/apps/api/.env.test @@ -18,6 +18,9 @@ JWT_REFRESH_SECRET=test-refresh-secret-that-is-at-least-32-chars GOOGLE_CLIENT_ID=test-google-client-id GOOGLE_CLIENT_SECRET=test-google-client-secret WEB_URL=http://localhost:3000 +GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/callback/google +GOOGLE_OAUTH_PKCE_TTL_SECONDS=300 +REFERRER_POLICY=strict-origin-when-cross-origin # Explicit CORS allow-list (required in every environment — no wildcard). ALLOWED_ORIGINS=http://localhost:3000 diff --git a/apps/api/src/db/queries/payouts.ts b/apps/api/src/db/queries/payouts.ts index 896fc68c..7120f05c 100644 --- a/apps/api/src/db/queries/payouts.ts +++ b/apps/api/src/db/queries/payouts.ts @@ -33,14 +33,12 @@ export async function createPayout(data: { VALUES ($1,$2,$3,$4) ON CONFLICT (challenge_id, user_id) DO UPDATE SET stellar_address = EXCLUDED.stellar_address, - amount_usdc = EXCLUDED.amount_usdc, + amount_stroops = EXCLUDED.amount_stroops, status = CASE WHEN payouts.status = 'failed' THEN 'pending' ELSE payouts.status END, error_message = NULL - RETURNING *`, - [data.challengeId, data.userId, data.stellarAddress, data.amountUsdc] RETURNING *, (amount_stroops::numeric / 10000000)::numeric(20,7)::text AS amount_usdc`, [data.challengeId, data.userId, data.stellarAddress, amountStroops] ); diff --git a/apps/api/src/db/queries/sessions.ts b/apps/api/src/db/queries/sessions.ts index c5b71435..d2d8152a 100644 --- a/apps/api/src/db/queries/sessions.ts +++ b/apps/api/src/db/queries/sessions.ts @@ -44,6 +44,15 @@ export interface LeaderboardSession extends GameSession { stellar_address: string | null; } +export const LEADERBOARD_SORTS = ["score", "rank", "created_at"] as const; +export type LeaderboardSort = (typeof LEADERBOARD_SORTS)[number]; + +const leaderboardOrderBy: Record = { + score: "gs.total_score DESC, gs.completed_at ASC, gs.id ASC", + rank: "gs.total_score DESC, gs.completed_at ASC, gs.id ASC", + created_at: "gs.created_at DESC, gs.total_score DESC, gs.id ASC", +}; + export async function createSession(data: { userId: string; challengeId: string; @@ -274,8 +283,10 @@ export async function markAbandonedSessions(): Promise { export async function getLeaderboard( challengeId: string, limit = 20, - offset = 0 + offset = 0, + sortBy: LeaderboardSort = "score" ): Promise { + const orderBy = leaderboardOrderBy[sortBy]; const result = await query( `SELECT gs.*, u.email AS username, @@ -294,7 +305,7 @@ export async function getLeaderboard( AND gs.is_practice = FALSE AND gs.status = 'completed' AND u.deleted_at IS NULL - ORDER BY gs.total_score DESC, gs.completed_at ASC + ORDER BY ${orderBy} LIMIT $2 OFFSET $3`, [challengeId, limit, offset] ); diff --git a/apps/api/src/helmet.test.ts b/apps/api/src/helmet.test.ts index e8877022..ee80afd9 100644 --- a/apps/api/src/helmet.test.ts +++ b/apps/api/src/helmet.test.ts @@ -1,8 +1,61 @@ -import { describe, it, expect } from "vitest"; +import type { Express } from "express"; +import { beforeAll, describe, it, expect, vi } from "vitest"; import request from "supertest"; -import { app } from "./index"; + +let app: Express; + +vi.mock("@brandblitz/stellar", () => ({ + MIN_POOL_STROOPS: 1_000_000_000, + WARMUP_MIN_SECONDS: 10, + EscrowClient: vi.fn(), + feeBumpTransaction: vi.fn(), + getHorizonServer: vi.fn(), + getAccountUsdcBalance: vi.fn(), + submitBatchPayout: vi.fn(), + drainSharedAgent: vi.fn(), +})); + +vi.mock("./routes/admin/escrow", () => ({ + default: (_req: unknown, _res: unknown, next: () => void) => next(), +})); + +vi.mock("./routes/admin", () => ({ + default: (_req: unknown, _res: unknown, next: () => void) => next(), +})); + +vi.mock("./routes/docs", () => ({ + default: (_req: unknown, _res: unknown, next: () => void) => next(), +})); + +vi.mock("./lib/redis", () => ({ + redis: { + call: vi.fn(), + sendCommand: vi.fn(), + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue("OK"), + del: vi.fn().mockResolvedValue(1), + scan: vi.fn().mockResolvedValue(["0", []]), + disconnect: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + }, + connectRedis: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./middleware/rate-limit", () => ({ + apiLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), + authLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), + challengeStartLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), + uploadLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), + webhookLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), + phoneRateLimit: (_req: unknown, _res: unknown, next: () => void) => next(), + webhookRotationLimiter: (_req: unknown, _res: unknown, next: () => void) => next(), +})); describe("Helmet Security Headers", () => { + beforeAll(async () => { + app = (await import("./index")).app; + }); + it("should include security headers on health endpoint", async () => { const response = await request(app).get("/health"); @@ -16,4 +69,13 @@ describe("Helmet Security Headers", () => { expect(csp).toContain("default-src 'self'"); expect(csp).toContain("frame-ancestors 'none'"); }); + + it.each(["/sessions", "/leaderboard/global", "/challenges"])( + "sets Referrer-Policy on %s", + async (path) => { + const response = await request(app).get(path); + + expect(response.headers["referrer-policy"]).toBe("strict-origin-when-cross-origin"); + } + ); }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index cd3e24ae..d108baa8 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -59,7 +59,7 @@ app.use( } : false, referrerPolicy: { - policy: "strict-origin-when-cross-origin", + policy: config.REFERRER_POLICY, }, xFrameOptions: { action: "deny", diff --git a/apps/api/src/lib/config-schema.ts b/apps/api/src/lib/config-schema.ts index 362d7a1b..0c0f5ff2 100644 --- a/apps/api/src/lib/config-schema.ts +++ b/apps/api/src/lib/config-schema.ts @@ -24,7 +24,8 @@ export const configSchema = z.object({ GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), WEB_URL: z.string().url().default("http://localhost:3000"), - + GOOGLE_REDIRECT_URI: z.string().url().optional(), + GOOGLE_OAUTH_PKCE_TTL_SECONDS: z.coerce.number().int().positive().max(900).default(300), /** * Comma-separated list of origins permitted by CORS. Required in EVERY * environment — there is intentionally no default and no wildcard fallback. @@ -49,6 +50,9 @@ export const configSchema = z.object({ .refine((origins) => !origins.includes("*"), { message: "ALLOWED_ORIGINS must not contain a wildcard '*'", }), + REFERRER_POLICY: z + .enum(["strict-origin-when-cross-origin", "no-referrer"]) + .default("strict-origin-when-cross-origin"), // Stellar STELLAR_NETWORK: z.enum(["testnet", "public"]).default("testnet"), diff --git a/apps/api/src/lib/config.test.ts b/apps/api/src/lib/config.test.ts index 08d6c7bb..de7f7073 100644 --- a/apps/api/src/lib/config.test.ts +++ b/apps/api/src/lib/config.test.ts @@ -22,6 +22,7 @@ const VALID_ENV: Record = { GOOGLE_CLIENT_ID: "google-client-id", GOOGLE_CLIENT_SECRET: "google-client-secret", WEB_URL: "http://localhost:3000", + ALLOWED_ORIGINS: "http://localhost:3000", STELLAR_NETWORK: "testnet", HOT_WALLET_SECRET: "SBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", HOT_WALLET_PUBLIC_KEY: "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", @@ -47,6 +48,8 @@ describe("configSchema — valid env", () => { expect(result.data.PORT).toBe(3001); expect(result.data.DB_POOL_MAX).toBe(10); expect(result.data.PAYOUT_WORKER_CONCURRENCY).toBe(2); + expect(result.data.GOOGLE_OAUTH_PKCE_TTL_SECONDS).toBe(300); + expect(result.data.REFERRER_POLICY).toBe("strict-origin-when-cross-origin"); }); it("coerces PORT from string to number", () => { diff --git a/apps/api/src/lib/openapi-registry.ts b/apps/api/src/lib/openapi-registry.ts index c32e40d2..cca424f7 100644 --- a/apps/api/src/lib/openapi-registry.ts +++ b/apps/api/src/lib/openapi-registry.ts @@ -28,7 +28,7 @@ * }, * }); * - * router.post("/google/callback", (req, res) => { /* ... */ }); + * router.post("/google/callback", (req, res) => { ... }); */ import { OpenAPIRegistry, extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; diff --git a/apps/api/src/middleware/error.test.ts b/apps/api/src/middleware/error.test.ts index 7233908a..862d8646 100644 --- a/apps/api/src/middleware/error.test.ts +++ b/apps/api/src/middleware/error.test.ts @@ -31,6 +31,7 @@ function makeResponse() { const status = vi.fn().mockReturnValue({ json }); return { + locals: { requestId: "req-test-123" }, status, json, } as any; @@ -65,7 +66,10 @@ describe("error middleware", () => { errorHandler(new Error("Boom"), req, res, vi.fn()); expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: "Internal Server Error" }); + expect(res.json).toHaveBeenCalledWith({ + error: "Internal Server Error", + requestId: "req-test-123", + }); }); it("includes stack trace in development only", () => { @@ -94,7 +98,31 @@ describe("error middleware", () => { errorHandler(error, req, res, vi.fn()); expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: "Internal Server Error" }); + expect(res.json).toHaveBeenCalledWith({ + error: "Internal Server Error", + requestId: "req-test-123", + }); + }); + + it("strips database error details from production 5xx responses", () => { + process.env.NODE_ENV = "production"; + const req = makeRequest(); + const res = makeResponse(); + const error = Object.assign(new Error("duplicate key violates unique constraint users_email_key"), { + code: "23505", + table: "users", + column: "email", + constraint: "users_email_key", + stack: "db-stack", + }); + + errorHandler(error as any, req, res, vi.fn()); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: "Internal Server Error", + requestId: "req-test-123", + }); }); it("maps ZodError to 400 with field-level details", () => { diff --git a/apps/api/src/middleware/error.ts b/apps/api/src/middleware/error.ts index 048bb4bf..5fa89c39 100644 --- a/apps/api/src/middleware/error.ts +++ b/apps/api/src/middleware/error.ts @@ -33,6 +33,8 @@ export function errorHandler( statusCode = statusCode ?? 500; const isServerError = statusCode >= 500; + const nodeEnv = process.env.NODE_ENV ?? config.NODE_ENV; + const isProduction = nodeEnv === "production"; if (isServerError) { message = "Internal Server Error"; @@ -49,15 +51,21 @@ export function errorHandler( captureExceptionSync(err, { method: req.method, url: req.url }); } - const payload: Record = { - error: message, - }; + const payload: Record = + isProduction && isServerError + ? { + error: "Internal Server Error", + requestId: res.locals.requestId, + } + : { + error: message, + }; - if (err.code) { + if (!(isProduction && isServerError) && err.code) { payload.code = err.code; } - if (err instanceof ZodError) { + if (!(isProduction && isServerError) && err instanceof ZodError) { payload.details = err.issues.map((issue) => ({ path: issue.path, message: issue.message, @@ -69,7 +77,7 @@ export function errorHandler( })); } - if (config.NODE_ENV === "development" && err.stack) { + if (nodeEnv === "development" && err.stack) { payload.stack = err.stack; } @@ -81,4 +89,4 @@ export function createError(message: string, statusCode: number, code?: string): err.statusCode = statusCode; err.code = code; return err; -} \ No newline at end of file +} diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index a8a4b35e..afdf82f5 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -18,6 +18,11 @@ import { webhookRotationLimiter } from "../middleware/rate-limit"; const router = Router(); +// Admin leaderboard-style queries must follow the same rule as +// routes/leaderboard.ts: validate sort params against an allowlist before +// choosing an ORDER BY expression. This file currently has no user-controlled +// leaderboard ORDER BY clauses. + router.use(authenticate); router.use(async (req, _res, next) => { diff --git a/apps/api/src/routes/admin/config.ts b/apps/api/src/routes/admin/config.ts index 4da9fb2f..f15eacf0 100644 --- a/apps/api/src/routes/admin/config.ts +++ b/apps/api/src/routes/admin/config.ts @@ -10,8 +10,6 @@ const router = Router(); router.use(authenticate); router.use(requireAdmin); -import { z } from "zod"; - const KnownConfigSchema = z.discriminatedUnion("key", [ z.object({ key: z.literal("anti_cheat"), diff --git a/apps/api/src/routes/auth.test.ts b/apps/api/src/routes/auth.test.ts index c05b4ddd..b4a85ca7 100644 --- a/apps/api/src/routes/auth.test.ts +++ b/apps/api/src/routes/auth.test.ts @@ -9,16 +9,63 @@ import type { User } from "../db/queries/users"; const mocks = vi.hoisted(() => ({ upsertUser: vi.fn(), findUserById: vi.fn(), + findUserByReferralCode: vi.fn(), + getUserReferralCode: vi.fn(), + setUserReferralCode: vi.fn(), + updateLastLogin: vi.fn(), + query: vi.fn(), verifyGoogleIdToken: vi.fn(), + exchangeGoogleAuthorizationCode: vi.fn(), + createGooglePkceAuthorizationUrl: vi.fn(), + ensureUserReferralCode: vi.fn(), + consumePendingReferralAttribution: vi.fn(), + redisGet: vi.fn(), + redisSet: vi.fn(), + redisSadd: vi.fn(), + redisExpire: vi.fn(), + redisSmembers: vi.fn(), + redisPipelineSet: vi.fn(), + redisPipelineDel: vi.fn(), + redisPipelineExec: vi.fn(), })); vi.mock("../db/queries/users", () => ({ upsertUser: mocks.upsertUser, findUserById: mocks.findUserById, + findUserByReferralCode: mocks.findUserByReferralCode, + getUserReferralCode: mocks.getUserReferralCode, + setUserReferralCode: mocks.setUserReferralCode, + updateLastLogin: mocks.updateLastLogin, +})); + +vi.mock("../db", () => ({ + query: mocks.query, +})); + +vi.mock("../lib/redis", () => ({ + redis: { + get: mocks.redisGet, + set: mocks.redisSet, + sadd: mocks.redisSadd, + expire: mocks.redisExpire, + smembers: mocks.redisSmembers, + pipeline: () => ({ + set: mocks.redisPipelineSet, + del: mocks.redisPipelineDel, + exec: mocks.redisPipelineExec, + }), + }, })); vi.mock("../services/google-auth", () => ({ verifyGoogleIdToken: mocks.verifyGoogleIdToken, + exchangeGoogleAuthorizationCode: mocks.exchangeGoogleAuthorizationCode, + createGooglePkceAuthorizationUrl: mocks.createGooglePkceAuthorizationUrl, +})); + +vi.mock("../services/referrals", () => ({ + ensureUserReferralCode: mocks.ensureUserReferralCode, + consumePendingReferralAttribution: mocks.consumePendingReferralAttribution, })); vi.mock("../middleware/rate-limit", () => ({ @@ -33,10 +80,18 @@ function createTestApp() { return app; } -function signAccessToken(user: Pick): string { - return jwt.sign({ sub: user.id, email: user.email }, process.env.JWT_SECRET!, { - expiresIn: "15m", - }); +function signAccessToken(user: Pick): string { + return jwt.sign( + { + sub: user.id, + email: user.email, + role: user.role ?? "player", + iss: "brandblitz-api", + aud: "brandblitz-client", + }, + process.env.JWT_SECRET!, + { expiresIn: "15m" } + ); } function signRefreshToken(user: Pick): string { @@ -58,6 +113,7 @@ const userFixture: User = { total_earned_usdc: "123.4500000", challenges_played: 12, role: "player", + status: "active", phone_hash: "secret-phone-hash", phone_verified: true, age_verified: true, @@ -76,14 +132,26 @@ const userFixture: User = { describe("auth routes", () => { beforeEach(() => { - process.env.JWT_SECRET = "test-jwt-secret-12345678901234567890"; - process.env.JWT_REFRESH_SECRET = "test-refresh-secret-1234567890123456"; vi.clearAllMocks(); + mocks.ensureUserReferralCode.mockResolvedValue("REF123"); + mocks.consumePendingReferralAttribution.mockResolvedValue(undefined); + mocks.findUserByReferralCode.mockResolvedValue(null); + mocks.getUserReferralCode.mockResolvedValue("REF123"); + mocks.setUserReferralCode.mockResolvedValue(undefined); + mocks.updateLastLogin.mockResolvedValue(undefined); + mocks.query.mockResolvedValue({ rows: [] }); + mocks.redisGet.mockResolvedValue(null); + mocks.redisSet.mockResolvedValue("OK"); + mocks.redisSadd.mockResolvedValue(1); + mocks.redisExpire.mockResolvedValue(1); + mocks.redisSmembers.mockResolvedValue([]); + mocks.redisPipelineSet.mockReturnThis(); + mocks.redisPipelineDel.mockReturnThis(); + mocks.redisPipelineExec.mockResolvedValue([]); }); afterEach(() => { - delete process.env.JWT_SECRET; - delete process.env.JWT_REFRESH_SECRET; + vi.unstubAllEnvs(); }); it("returns a JWT and refresh token for a valid Google token", async () => { @@ -114,6 +182,7 @@ describe("auth routes", () => { username: userFixture.username, avatarUrl: userFixture.avatar_url, role: userFixture.role, + status: "active", }); const accessPayload = jwt.verify(response.body.token, process.env.JWT_SECRET!) as { @@ -131,6 +200,51 @@ describe("auth routes", () => { expect(refreshPayload.type).toBe("refresh"); }); + it("starts a Google PKCE authorization flow", async () => { + mocks.createGooglePkceAuthorizationUrl.mockResolvedValue({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=state-123", + state: "state-123", + codeChallenge: "challenge-123", + codeChallengeMethod: "S256", + expiresIn: 300, + }); + + const response = await request(createTestApp()) + .get("/auth/google/authorize") + .query({ callbackUrl: "/leaderboard" }) + .expect(200); + + expect(mocks.createGooglePkceAuthorizationUrl).toHaveBeenCalledWith("/leaderboard"); + expect(response.body).toEqual({ + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=state-123", + state: "state-123", + codeChallenge: "challenge-123", + codeChallengeMethod: "S256", + expiresIn: 300, + }); + }); + + it("exchanges a Google authorization code with PKCE state", async () => { + mocks.exchangeGoogleAuthorizationCode.mockResolvedValue({ + googleId: "google-123", + email: userFixture.email, + name: userFixture.display_name, + avatarUrl: userFixture.avatar_url, + }); + mocks.upsertUser.mockResolvedValue(userFixture); + + const response = await request(createTestApp()) + .post("/auth/google/callback") + .send({ code: "google-code", state: "oauth-state" }) + .expect(200); + + expect(mocks.exchangeGoogleAuthorizationCode).toHaveBeenCalledWith({ + code: "google-code", + state: "oauth-state", + }); + expect(response.body.user.id).toBe(userFixture.id); + }); + it("returns the existing user record for an existing Google ID", async () => { mocks.verifyGoogleIdToken.mockResolvedValue({ googleId: userFixture.google_id, @@ -181,6 +295,7 @@ describe("auth routes", () => { username: userFixture.username, avatarUrl: userFixture.avatar_url, role: userFixture.role, + status: "active", }); expect(response.body.user.google_id).toBeUndefined(); expect(response.body.user.phone_hash).toBeUndefined(); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 9411d8cf..3fc2a218 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -4,7 +4,11 @@ import { findUserById, upsertUser, updateLastLogin } from "../db/queries/users"; import { createError } from "../middleware/error"; import { authLimiter } from "../middleware/rate-limit"; import { authenticate } from "../middleware/authenticate"; -import { verifyGoogleIdToken } from "../services/google-auth"; +import { + createGooglePkceAuthorizationUrl, + exchangeGoogleAuthorizationCode, + verifyGoogleIdToken, +} from "../services/google-auth"; import { consumePendingReferralAttribution, ensureUserReferralCode, @@ -22,7 +26,18 @@ import { query } from "../db"; const router = Router(); -const GoogleCallbackSchema = z.object({ idToken: z.string().min(1) }); +const GoogleAuthorizeSchema = z.object({ + callbackUrl: z.string().default("/"), +}); +const GoogleCallbackSchema = z + .object({ + idToken: z.string().min(1).optional(), + code: z.string().min(1).optional(), + state: z.string().min(1).optional(), + }) + .refine((body) => body.idToken || (body.code && body.state), { + message: "idToken or code and state are required", + }); const RefreshTokenSchema = z.object({ refreshToken: z.string().min(1) }); function serializeUser(user: { @@ -46,9 +61,17 @@ function serializeUser(user: { } /** POST /auth/google/callback */ +router.get("/google/authorize", authLimiter, async (req, res) => { + const { callbackUrl } = GoogleAuthorizeSchema.parse(req.query); + const authorization = await createGooglePkceAuthorizationUrl(callbackUrl); + res.json(authorization); +}); + router.post("/google/callback", authLimiter, async (req, res) => { - const { idToken } = GoogleCallbackSchema.parse(req.body); - const profile = await verifyGoogleIdToken(idToken); + const { idToken, code, state } = GoogleCallbackSchema.parse(req.body); + const profile = idToken + ? await verifyGoogleIdToken(idToken) + : await exchangeGoogleAuthorizationCode({ code: code!, state: state! }); const user = await upsertUser({ email: profile.email, googleId: profile.googleId, diff --git a/apps/api/src/routes/challenges.ts b/apps/api/src/routes/challenges.ts index 1952cd78..f801d856 100644 --- a/apps/api/src/routes/challenges.ts +++ b/apps/api/src/routes/challenges.ts @@ -8,7 +8,12 @@ import { getChallengeQuestions, } from "../db/queries/challenges"; import { getBrandById } from "../db/queries/brands"; -import { getLeaderboard, getArchivedLeaderboard } from "../db/queries/sessions"; +import { + getLeaderboard, + getArchivedLeaderboard, + LEADERBOARD_SORTS, + type LeaderboardSort, +} from "../db/queries/sessions"; import { optionalAuth, authenticate } from "../middleware/authenticate"; import { createError } from "../middleware/error"; import { withCoalescing } from "../lib/cache"; @@ -17,6 +22,20 @@ import { query } from "../db/index"; const router = Router(); +const LeaderboardSortSchema = z.enum(LEADERBOARD_SORTS).default("score"); + +function parseLeaderboardSort(query: Record): LeaderboardSort { + const parsed = LeaderboardSortSchema.safeParse(query.sort_by ?? query.order); + if (!parsed.success) { + throw createError( + `Invalid leaderboard sort. Allowed values: ${LEADERBOARD_SORTS.join(", ")}`, + 400, + "INVALID_SORT", + ); + } + return parsed.data; +} + const CursorPaginationSchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).default(20), @@ -113,9 +132,10 @@ router.get("/:id/leaderboard", async (req, res) => { limit: z.coerce.number().int().min(1).max(100).default(20), offset: z.coerce.number().int().min(0).default(0), }).parse(req.query); + const sortBy = parseLeaderboardSort(req.query); const sessions = challenge.archived ? await getArchivedLeaderboard(challenge.id, limit, offset) - : await getLeaderboard(challenge.id, limit, offset); + : await getLeaderboard(challenge.id, limit, offset, sortBy); res.json({ challengeId: challenge.id, diff --git a/apps/api/src/routes/leaderboard.test.ts b/apps/api/src/routes/leaderboard.test.ts index 795f4e13..6ab16f88 100644 --- a/apps/api/src/routes/leaderboard.test.ts +++ b/apps/api/src/routes/leaderboard.test.ts @@ -2,6 +2,7 @@ import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import leaderboardRouter from "./leaderboard"; +import { errorHandler } from "../middleware/error"; // ─── Hoisted mocks ──────────────────────────────────────────────────────────── @@ -12,6 +13,8 @@ const mocks = vi.hoisted(() => ({ getLeaderboard: vi.fn(), redisGet: vi.fn(), redisSet: vi.fn(), + redisDel: vi.fn(), + redisExists: vi.fn(), dbQueryCount: { value: 0 }, })); @@ -20,6 +23,7 @@ vi.mock("../db/queries/challenges", () => ({ })); vi.mock("../db/queries/sessions", () => ({ + LEADERBOARD_SORTS: ["score", "rank", "created_at"], getLeaderboard: (...args: unknown[]) => { mocks.dbQueryCount.value++; return mocks.getLeaderboard(...args); @@ -38,6 +42,8 @@ vi.mock("../lib/redis", () => ({ redis: { get: mocks.redisGet, set: mocks.redisSet, + del: mocks.redisDel, + exists: mocks.redisExists, }, })); @@ -47,6 +53,7 @@ function createApp() { const app = express(); app.use(express.json()); app.use("/leaderboard", leaderboardRouter); + app.use(errorHandler); return app; } @@ -102,16 +109,37 @@ describe("GET /leaderboard/global", () => { mocks.dbQueryCount.value = 0; mocks.redisGet.mockResolvedValue(null); mocks.redisSet.mockResolvedValue("OK"); + mocks.redisDel.mockResolvedValue(1); + mocks.redisExists.mockResolvedValue(0); mocks.getActiveChallenges.mockResolvedValue(CHALLENGES); mocks.getGlobalLeaderboardFromView.mockResolvedValue(VIEW_ROWS); }); it("returns 200 with a leaderboard array", async () => { - const res = await request(createApp()).get("/leaderboard/global"); + const res = await request(createApp()).get("/leaderboard/global?sort_by=score"); expect(res.status).toBe(200); expect(Array.isArray(res.body.leaderboard)).toBe(true); }); + it("rejects invalid sort values", async () => { + const res = await request(createApp()).get("/leaderboard/global?sort_by=total_score"); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + error: "Invalid leaderboard sort. Allowed values: score, rank, created_at", + code: "INVALID_SORT", + }); + }); + + it("rejects SQL injection probes in sort_by", async () => { + const res = await request(createApp()).get( + "/leaderboard/global?sort_by=score%3B%20DROP%20TABLE%20users--" + ); + + expect(res.status).toBe(400); + expect(mocks.getGlobalLeaderboardFromView).not.toHaveBeenCalled(); + }); + it("cache fallback path reads from the materialised view, not the raw tables", async () => { await request(createApp()).get("/leaderboard/global"); // One DB call: getGlobalLeaderboardFromView; raw aggregate scan is NOT used @@ -166,6 +194,7 @@ describe("GET /leaderboard/global", () => { it("returns the cached payload without hitting the DB on a cache hit", async () => { const cachedPayload = { leaderboard: [{ rank: 1, challengeId: "challenge-aaa", username: "cached", avatarUrl: null, totalScore: 999 }], + data: [{ rank: 1, challengeId: "challenge-aaa", username: "cached", avatarUrl: null, totalScore: 999 }], cachedAt: "2026-01-01T00:00:00.000Z", }; mocks.redisGet.mockResolvedValue(JSON.stringify(cachedPayload)); @@ -186,6 +215,38 @@ describe("GET /leaderboard/global", () => { expect(res.status).toBe(200); expect(res.body.leaderboard).toEqual([]); }); + + it("accepts allowlisted sort values", async () => { + const res = await request(createApp()) + .get("/leaderboard/global") + .query({ sort_by: "score" }); + + expect(res.status).toBe(200); + expect(mocks.getGlobalLeaderboardFromView).toHaveBeenCalled(); + }); + + it("rejects invalid sort values", async () => { + const res = await request(createApp()) + .get("/leaderboard/global") + .query({ sort_by: "email" }); + + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + error: "Invalid leaderboard sort. Allowed values: score, rank, created_at", + code: "INVALID_SORT", + }); + expect(mocks.getGlobalLeaderboardFromView).not.toHaveBeenCalled(); + }); + + it("rejects SQL-injection probe sort values", async () => { + const res = await request(createApp()) + .get("/leaderboard/global") + .query({ sort_by: "score; DROP TABLE users--" }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe("INVALID_SORT"); + expect(mocks.getGlobalLeaderboardFromView).not.toHaveBeenCalled(); + }); }); describe("GET /leaderboard/:challengeId", () => { @@ -211,9 +272,27 @@ describe("GET /leaderboard/:challengeId", () => { it("passes limit and offset to getLeaderboard", async () => { await request(createApp()) .get("/leaderboard/c1") - .query({ limit: 5, offset: 10 }); + .query({ limit: 5, offset: 10, order: "rank" }); - expect(mocks.getLeaderboard).toHaveBeenCalledWith("c1", 5, 10); + expect(mocks.getLeaderboard).toHaveBeenCalledWith("c1", 6, 10, "rank"); + }); + + it("passes valid sort values to the leaderboard query", async () => { + await request(createApp()) + .get("/leaderboard/c1") + .query({ limit: 5, sort_by: "created_at" }); + + expect(mocks.getLeaderboard).toHaveBeenCalledWith("c1", 6, 0, "created_at"); + }); + + it("rejects invalid challenge leaderboard sort values", async () => { + const res = await request(createApp()) + .get("/leaderboard/c1") + .query({ order: "score; DROP TABLE users--" }); + + expect(res.status).toBe(400); + expect(res.body.code).toBe("INVALID_SORT"); + expect(mocks.getLeaderboard).not.toHaveBeenCalled(); }); it("issues exactly one leaderboard query regardless of participant count", async () => { diff --git a/apps/api/src/routes/leaderboard.ts b/apps/api/src/routes/leaderboard.ts index 8b7eada3..b4d9e93f 100644 --- a/apps/api/src/routes/leaderboard.ts +++ b/apps/api/src/routes/leaderboard.ts @@ -5,11 +5,34 @@ import { getLeaderboard, getTopSessionsPerChallenge, getGlobalLeaderboardFromView, + LEADERBOARD_SORTS, + type LeaderboardSort, } from "../db/queries/sessions"; import { withCoalescing } from "../lib/cache"; +import { createError } from "../middleware/error"; const router = Router(); +// Keep leaderboard ORDER BY clauses static or selected from this allowlist only. +// User query params must never be concatenated directly into SQL strings. +const LeaderboardSortSchema = z.enum(LEADERBOARD_SORTS).default("score"); + +function parseLeaderboardSort(query: unknown): LeaderboardSort { + const raw = + typeof query === "object" && query !== null + ? ((query as Record).sort_by ?? (query as Record).order) + : undefined; + const parsed = LeaderboardSortSchema.safeParse(raw); + if (!parsed.success) { + throw createError( + `Invalid leaderboard sort. Allowed values: ${LEADERBOARD_SORTS.join(", ")}`, + 400, + "INVALID_SORT" + ); + } + return parsed.data; +} + function writeSse(res: any, payload: unknown) { res.write(`data: ${JSON.stringify(payload)}\n\n`); } @@ -23,6 +46,7 @@ function writeSse(res: any, payload: unknown) { * - intervalMs?: number (default 2000, min 500) */ router.get("/stream", async (req, res) => { + parseLeaderboardSort(req.query); const { challengeId, intervalMs } = z.object({ challengeId: z.string().optional(), intervalMs: z.coerce.number().min(500).max(30_000).default(2000), @@ -102,14 +126,15 @@ router.get("/stream", async (req, res) => { * Single aggregated query via ROW_NUMBER() — no N+1. */ router.get("/global", async (req, res) => { + const sortBy = parseLeaderboardSort(req.query); const { limit, cursor } = z.object({ limit: z.coerce.number().min(1).max(100).default(50), cursor: z.string().optional(), }).parse(req.query); const cacheKey = cursor - ? `leaderboard:global:${cursor}:${limit}` - : `leaderboard:global:first:${limit}`; + ? `leaderboard:global:${sortBy}:${cursor}:${limit}` + : `leaderboard:global:${sortBy}:first:${limit}`; const response = await withCoalescing(cacheKey, 300, async () => { const challenges = await getActiveChallenges(10); @@ -141,6 +166,7 @@ router.get("/global", async (req, res) => { const nextCursor = page.length > 0 ? String(page[page.length - 1].rank) : null; return { + leaderboard: page, data: page, nextCursor: hasMore ? nextCursor : null, cachedAt: new Date().toISOString(), @@ -154,8 +180,10 @@ router.get("/global", async (req, res) => { * GET /leaderboard/:challengeId */ router.get("/:challengeId", async (req, res) => { - const { limit, cursor } = z.object({ - limit: z.coerce.number().default(20), + const sortBy = parseLeaderboardSort(req.query); + const { limit, offset, cursor } = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), cursor: z.string().optional(), }).parse(req.query); @@ -169,7 +197,7 @@ router.get("/:challengeId", async (req, res) => { cursorId = parts[1]; } - const sessions = await getLeaderboard(req.params.challengeId, limit + 1, 0); + const sessions = await getLeaderboard(req.params.challengeId, limit + 1, offset, sortBy); // Apply cursor filter on the result set let filtered = sessions; @@ -186,18 +214,21 @@ router.get("/:challengeId", async (req, res) => { const lastItem = page[page.length - 1]; const nextCursor = lastItem ? `${lastItem.total_score}:${lastItem.id}` : null; + const mappedSessions = page.map((s, i) => ({ + rank: offset + i + 1, + userId: s.user_id, + username: s.username, + displayName: s.display_name, + league: s.league, + avatarUrl: s.avatar_url, + totalScore: s.total_score, + totalEarned: s.total_earned_usdc, + endedAt: s.completed_at, + })); + res.json({ - data: page.map((s, i) => ({ - rank: i + 1, - userId: s.user_id, - username: s.username, - displayName: s.display_name, - league: s.league, - avatarUrl: s.avatar_url, - totalScore: s.total_score, - totalEarned: s.total_earned_usdc, - endedAt: s.completed_at, - })), + sessions: mappedSessions, + data: mappedSessions, nextCursor: hasMore ? nextCursor : null, }); }); diff --git a/apps/api/src/services/google-auth.test.ts b/apps/api/src/services/google-auth.test.ts new file mode 100644 index 00000000..a51d6533 --- /dev/null +++ b/apps/api/src/services/google-auth.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + redisSet: vi.fn(), + redisGet: vi.fn(), + redisDel: vi.fn(), +})); + +vi.mock("../lib/redis", () => ({ + redis: { + set: mocks.redisSet, + get: mocks.redisGet, + del: mocks.redisDel, + }, +})); + +import { + createCodeChallenge, + createGooglePkceAuthorizationUrl, + exchangeGoogleAuthorizationCode, +} from "./google-auth"; + +describe("google-auth PKCE", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("creates an S256 authorization URL and stores a short-lived verifier in Redis", async () => { + mocks.redisSet.mockResolvedValue("OK"); + + const challenge = await createGooglePkceAuthorizationUrl("/dashboard"); + const url = new URL(challenge.authorizationUrl); + const stored = JSON.parse(mocks.redisSet.mock.calls[0][1]); + + expect(challenge.codeChallengeMethod).toBe("S256"); + expect(stored.codeVerifier).toHaveLength(43); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBe(createCodeChallenge(stored.codeVerifier)); + expect(url.searchParams.get("state")).toBe(challenge.state); + expect(mocks.redisSet).toHaveBeenCalledWith( + `oauth:google:pkce:${challenge.state}`, + expect.any(String), + "EX", + 300, + "NX" + ); + }); + + it("retrieves the verifier by state and sends it during token exchange", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id_token: "google-id-token" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sub: "google-123", + email: "player@example.com", + aud: "test-google-client-id", + email_verified: "true", + }), + }); + vi.stubGlobal("fetch", fetchMock); + mocks.redisGet.mockResolvedValue(JSON.stringify({ codeVerifier: "a".repeat(43) })); + mocks.redisDel.mockResolvedValue(1); + + const profile = await exchangeGoogleAuthorizationCode({ + code: "auth-code", + state: "state-123", + }); + + const body = fetchMock.mock.calls[0][1].body as URLSearchParams; + expect(body.get("code")).toBe("auth-code"); + expect(body.get("code_verifier")).toBe("a".repeat(43)); + expect(mocks.redisGet).toHaveBeenCalledWith("oauth:google:pkce:state-123"); + expect(mocks.redisDel).toHaveBeenCalledWith("oauth:google:pkce:state-123"); + expect(profile.googleId).toBe("google-123"); + + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/api/src/services/google-auth.ts b/apps/api/src/services/google-auth.ts index d520a739..ec3f7731 100644 --- a/apps/api/src/services/google-auth.ts +++ b/apps/api/src/services/google-auth.ts @@ -1,7 +1,8 @@ import { createError } from "../middleware/error"; import { config } from "../lib/config"; import { z } from "zod"; -import { config } from "../lib/config"; +import { randomBytes, createHash } from "crypto"; +import { redis } from "../lib/redis"; const GoogleTokenInfoSchema = z.object({ sub: z.string().min(1), @@ -12,6 +13,10 @@ const GoogleTokenInfoSchema = z.object({ picture: z.string().url().optional(), }); +const GoogleTokenResponseSchema = z.object({ + id_token: z.string().min(1), +}); + export interface VerifiedGoogleUser { googleId: string; email: string; @@ -19,6 +24,82 @@ export interface VerifiedGoogleUser { avatarUrl?: string; } +const PKCE_KEY_PREFIX = "oauth:google:pkce:"; +const PKCE_STATE_BYTES = 32; +const PKCE_VERIFIER_BYTES = 32; + +function base64Url(buffer: Buffer): string { + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +export function createCodeChallenge(codeVerifier: string): string { + return base64Url(createHash("sha256").update(codeVerifier).digest()); +} + +function googleRedirectUri(): string { + return config.GOOGLE_REDIRECT_URI ?? `${config.WEB_URL.replace(/\/$/, "")}/api/auth/callback/google`; +} + +export async function createGooglePkceAuthorizationUrl(callbackUrl = "/"): Promise<{ + authorizationUrl: string; + state: string; + codeChallenge: string; + codeChallengeMethod: "S256"; + expiresIn: number; +}> { + const state = base64Url(randomBytes(PKCE_STATE_BYTES)); + const codeVerifier = base64Url(randomBytes(PKCE_VERIFIER_BYTES)); + const codeChallenge = createCodeChallenge(codeVerifier); + const expiresIn = config.GOOGLE_OAUTH_PKCE_TTL_SECONDS; + + const stored = await redis.set( + `${PKCE_KEY_PREFIX}${state}`, + JSON.stringify({ codeVerifier, callbackUrl }), + "EX", + expiresIn, + "NX" + ); + if (stored !== "OK") { + throw createError("Unable to start Google OAuth flow", 500, "OAUTH_STATE_STORE_FAILED"); + } + + const params = new URLSearchParams({ + client_id: config.GOOGLE_CLIENT_ID, + redirect_uri: googleRedirectUri(), + response_type: "code", + scope: "openid email profile", + state, + code_challenge: codeChallenge, + code_challenge_method: "S256", + access_type: "offline", + prompt: "select_account", + }); + + return { + authorizationUrl: `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`, + state, + codeChallenge, + codeChallengeMethod: "S256", + expiresIn, + }; +} + +async function consumeCodeVerifier(state: string): Promise { + const key = `${PKCE_KEY_PREFIX}${state}`; + const raw = await redis.get(key); + if (!raw) { + throw createError("Invalid or expired OAuth state", 400, "INVALID_OAUTH_STATE"); + } + + await redis.del(key); + const parsed = z.object({ codeVerifier: z.string().min(43) }).parse(JSON.parse(raw)); + return parsed.codeVerifier; +} + export async function verifyGoogleIdToken(idToken: string): Promise { if (config.E2E_MOCK_GOOGLE_OAUTH === "true" && idToken.startsWith("e2e:")) { const [, rawEmail = "e2e-player@example.com", rawName = "E2E Player"] = idToken.split(":"); @@ -54,3 +135,31 @@ export async function verifyGoogleIdToken(idToken: string): Promise { + const codeVerifier = await consumeCodeVerifier(params.state); + const body = new URLSearchParams({ + code: params.code, + client_id: config.GOOGLE_CLIENT_ID, + client_secret: config.GOOGLE_CLIENT_SECRET, + redirect_uri: googleRedirectUri(), + grant_type: "authorization_code", + code_verifier: codeVerifier, + }); + + const response = await fetch("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + if (!response.ok) { + throw createError("Invalid Google authorization code", 401, "INVALID_GOOGLE_CODE"); + } + + const tokenResponse = GoogleTokenResponseSchema.parse(await response.json()); + return verifyGoogleIdToken(tokenResponse.id_token); +} diff --git a/apps/api/src/services/payout.ts b/apps/api/src/services/payout.ts index 43d8ce0c..e6b2e2f4 100644 --- a/apps/api/src/services/payout.ts +++ b/apps/api/src/services/payout.ts @@ -36,7 +36,8 @@ export function isFraudBlockError(err: unknown): boolean { * Enqueue a payout job for a completed challenge. * The actual Stellar transactions are processed by the BullMQ worker. */ - +export async function enqueuePayout(challengeId: string, requestId?: string): Promise { + await enqueuePayoutJob(challengeId); await enqueueLeaderboardRefresh(challengeId); logger.info("Payout job enqueued", { challengeId, requestId }); } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 491a2f3c..5e32302c 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -30,7 +30,7 @@ function getAllowedOrigins() { * - Prod/staging: S3 host via NEXT_PUBLIC_CDN_HOST or NEXT_PUBLIC_S3_HOST (e.g., assets.brandblitz.app) */ function getImageRemotePatterns() { - const patterns = [ + const patterns: NonNullable["remotePatterns"]> = [ // Google OAuth avatars { protocol: "https" as const, hostname: "lh3.googleusercontent.com" }, ]; @@ -86,6 +86,20 @@ const nextConfig: NextConfig = { allowedOrigins: getAllowedOrigins(), }, }, + + async headers() { + return [ + { + source: "/:path*", + headers: [ + { + key: "Referrer-Policy", + value: process.env.REFERRER_POLICY ?? "strict-origin-when-cross-origin", + }, + ], + }, + ]; + }, }; export default withAnalyzer(nextConfig); diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index b284116d..8af09dab 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -38,6 +38,12 @@ export const authOptions: NextAuthOptions = { GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + checks: ["pkce", "state"], + authorization: { + params: { + code_challenge_method: "S256", + }, + }, }), ], diff --git a/apps/web/src/next-config.test.ts b/apps/web/src/next-config.test.ts index 855636e1..bea10b61 100644 --- a/apps/web/src/next-config.test.ts +++ b/apps/web/src/next-config.test.ts @@ -11,22 +11,31 @@ describe("next image remote patterns", () => { }, { "hostname": "localhost", - "pathname": "/brandblitz/**", + "pathname": "/**", "port": "9000", "protocol": "http", }, { "hostname": "127.0.0.1", - "pathname": "/brandblitz/**", + "pathname": "/**", "port": "9000", "protocol": "http", }, - { - "hostname": "assets.brandblitz.app", - "pathname": "/brandblitz/**", - "protocol": "https", - }, ] `); }); + + it("sets Referrer-Policy on all page responses", async () => { + await expect(nextConfig.headers?.()).resolves.toEqual([ + { + source: "/:path*", + headers: [ + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + ], + }, + ]); + }); }); diff --git a/e2e/tests/referrer-policy.spec.ts b/e2e/tests/referrer-policy.spec.ts new file mode 100644 index 00000000..a28046b1 --- /dev/null +++ b/e2e/tests/referrer-policy.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from "@playwright/test"; + +test("page navigation responses include Referrer-Policy", async ({ page }) => { + const response = await page.goto("/leaderboard"); + + expect(response?.headers()["referrer-policy"]).toBe("strict-origin-when-cross-origin"); +});