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:
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user