fix(auth): defer env validation to first access so build works without secrets

`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 <noreply@anthropic.com>
This commit is contained in:
emil
2026-05-15 23:45:03 +03:00
co-authored by Claude Opus 4.7
parent f2afaf60a3
commit 4b78e9edad
4 changed files with 51 additions and 23 deletions
+9 -6
View File
@@ -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');
});
});
+18 -7
View File
@@ -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<typeof envSchema>;
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];
}
+9 -3
View File
@@ -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<string> {
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') {
+15 -7
View File
@@ -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<string> {
.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 {