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 {