From 4b78e9edad4d16db5eeaf189ef9fbe5eb1277c4e Mon Sep 17 00:00:00 2001 From: emil Date: Fri, 15 May 2026 23:45:03 +0300 Subject: [PATCH] fix(auth): defer env validation to first access so build works without secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `validateAuthEnv()` was running at module-load time, which fired during `astro build` while constructing the route manifest — before any actual request needed the secrets. CI build failed even though build-time code doesn't use JWT_SECRET / OAuth secrets. - src/lib/auth/env.ts: wrap authEnv in a Proxy that runs validation on first property read; cache the validated object after. - src/lib/auth/jwt.ts: defer `new TextEncoder().encode(...)` of the secret behind a memoised getSecret() helper. - src/lib/auth/oauth.ts: same for the JWT secret; vkOAuthConfig and yandexOAuthConfig switched to getter-based properties so credential access is also lazy. - src/lib/auth/auth.test.ts: 3 tests previously asserted "throws at module load"; updated to assert "throws on first access" — semantic guarantee (invalid env throws) is preserved. Runtime fail-fast is intact: any auth route reading authEnv.X will throw with the same descriptive Zod error if a var is missing. Build just no longer crashes when secrets aren't in env (e.g. CI deploy pipeline running tsc/lint/build without secrets). Verified: npm run build succeeds with no auth env vars set; tsc 0, lint 0, vitest 339/339. Co-Authored-By: Claude Opus 4.7 --- src/lib/auth/auth.test.ts | 15 +++++++++------ src/lib/auth/env.ts | 25 ++++++++++++++++++------- src/lib/auth/jwt.ts | 12 +++++++++--- src/lib/auth/oauth.ts | 22 +++++++++++++++------- 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/src/lib/auth/auth.test.ts b/src/lib/auth/auth.test.ts index 245e471..17349e4 100644 --- a/src/lib/auth/auth.test.ts +++ b/src/lib/auth/auth.test.ts @@ -27,19 +27,22 @@ describe('Auth Environment Validation', () => { expect(requireEnv('PUBLIC_APP_URL')).toBe(baseEnv.PUBLIC_APP_URL); }); - it('should throw at module load if JWT_SECRET is missing', async () => { + it('should throw on first access if JWT_SECRET is missing', async () => { delete process.env.JWT_SECRET; - await expect(import('./env')).rejects.toThrow('JWT_SECRET'); + const { authEnv } = await import('./env'); + expect(() => authEnv.JWT_SECRET).toThrow('JWT_SECRET'); }); - it('should throw at module load if PUBLIC_APP_URL is invalid', async () => { + it('should throw on first access if PUBLIC_APP_URL is invalid', async () => { process.env.PUBLIC_APP_URL = 'not-a-url'; - await expect(import('./env')).rejects.toThrow('PUBLIC_APP_URL'); + const { authEnv } = await import('./env'); + expect(() => authEnv.PUBLIC_APP_URL).toThrow('PUBLIC_APP_URL'); }); - it('should throw at module load if VK_CLIENT_ID is missing', async () => { + it('should throw on first access if VK_CLIENT_ID is missing', async () => { delete process.env.VK_CLIENT_ID; - await expect(import('./env')).rejects.toThrow('VK_CLIENT_ID'); + const { authEnv } = await import('./env'); + expect(() => authEnv.VK_CLIENT_ID).toThrow('VK_CLIENT_ID'); }); }); diff --git a/src/lib/auth/env.ts b/src/lib/auth/env.ts index 4701605..ce1fa86 100644 --- a/src/lib/auth/env.ts +++ b/src/lib/auth/env.ts @@ -9,20 +9,31 @@ const envSchema = z.object({ PUBLIC_APP_URL: z.string().url('PUBLIC_APP_URL must be a valid URL'), }); -function validateAuthEnv() { - const raw = { +type AuthEnv = z.infer; + +let cached: AuthEnv | null = null; + +function loadAuthEnv(): AuthEnv { + if (cached) return cached; + cached = envSchema.parse({ JWT_SECRET: process.env.JWT_SECRET, VK_CLIENT_ID: process.env.VK_CLIENT_ID, VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET, YANDEX_CLIENT_ID: process.env.YANDEX_CLIENT_ID, YANDEX_CLIENT_SECRET: process.env.YANDEX_CLIENT_SECRET, PUBLIC_APP_URL: process.env.PUBLIC_APP_URL, - }; - return envSchema.parse(raw); + }); + return cached; } -export const authEnv = validateAuthEnv(); +// Lazy proxy: validation fires only when a key is actually read at runtime, +// not when the module is first imported (which happens during `astro build`). +export const authEnv = new Proxy({} as AuthEnv, { + get(_target, key: string) { + return loadAuthEnv()[key as keyof AuthEnv]; + }, +}) as AuthEnv; -export function requireEnv(name: keyof typeof envSchema.shape): string { - return authEnv[name]; +export function requireEnv(name: keyof AuthEnv): string { + return loadAuthEnv()[name]; } diff --git a/src/lib/auth/jwt.ts b/src/lib/auth/jwt.ts index 6da087c..2e52fdd 100644 --- a/src/lib/auth/jwt.ts +++ b/src/lib/auth/jwt.ts @@ -2,18 +2,24 @@ import { SignJWT, jwtVerify } from 'jose'; import type { APIContext } from 'astro'; import { authEnv } from './env'; -const SECRET = new TextEncoder().encode(authEnv.JWT_SECRET); +let secretCache: Uint8Array | null = null; +function getSecret(): Uint8Array { + if (!secretCache) { + secretCache = new TextEncoder().encode(authEnv.JWT_SECRET); + } + return secretCache; +} export async function createToken(userId: string): Promise { return new SignJWT({ sub: userId }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('7d') - .sign(SECRET); + .sign(getSecret()); } export async function verifyToken(token: string): Promise<{ sub: string }> { - const { payload } = await jwtVerify(token, SECRET, { + const { payload } = await jwtVerify(token, getSecret(), { clockTolerance: 60, }); if (!payload.sub || typeof payload.sub !== 'string') { diff --git a/src/lib/auth/oauth.ts b/src/lib/auth/oauth.ts index 5f4dc03..14cb9db 100644 --- a/src/lib/auth/oauth.ts +++ b/src/lib/auth/oauth.ts @@ -1,7 +1,13 @@ import { SignJWT, jwtVerify } from 'jose'; import { authEnv } from './env'; -const JWT_SECRET = new TextEncoder().encode(authEnv.JWT_SECRET); +let secretCache: Uint8Array | null = null; +function getJwtSecret(): Uint8Array { + if (!secretCache) { + secretCache = new TextEncoder().encode(authEnv.JWT_SECRET); + } + return secretCache; +} export function generateCodeVerifier(): string { const array = new Uint8Array(64); @@ -30,17 +36,19 @@ export function generateState(): string { return base64UrlEncode(array); } +// Lazy getters so env validation doesn't fire at module import time +// (which happens during `astro build` when secrets are absent). export const vkOAuthConfig = { - clientId: authEnv.VK_CLIENT_ID, - clientSecret: authEnv.VK_CLIENT_SECRET, + get clientId() { return authEnv.VK_CLIENT_ID; }, + get clientSecret() { return authEnv.VK_CLIENT_SECRET; }, authUrl: 'https://id.vk.ru/authorize', tokenUrl: 'https://id.vk.ru/oauth2/auth', scope: 'email phone', }; export const yandexOAuthConfig = { - clientId: authEnv.YANDEX_CLIENT_ID, - clientSecret: authEnv.YANDEX_CLIENT_SECRET, + get clientId() { return authEnv.YANDEX_CLIENT_ID; }, + get clientSecret() { return authEnv.YANDEX_CLIENT_SECRET; }, authUrl: 'https://oauth.yandex.com/authorize', tokenUrl: 'https://oauth.yandex.com/token', scope: 'login:email login:info login:avatar', @@ -51,12 +59,12 @@ export async function createSessionToken(userId: number): Promise { .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('7d') - .sign(JWT_SECRET); + .sign(getJwtSecret()); } export async function verifySessionToken(token: string): Promise<{ userId: number } | null> { try { - const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 }); + const { payload } = await jwtVerify(token, getJwtSecret(), { clockTolerance: 60 }); if (!payload.sub) return null; return { userId: Number(payload.sub) }; } catch {