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);
|
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;
|
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';
|
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;
|
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'),
|
PUBLIC_APP_URL: z.string().url('PUBLIC_APP_URL must be a valid URL'),
|
||||||
});
|
});
|
||||||
|
|
||||||
function validateAuthEnv() {
|
type AuthEnv = z.infer<typeof envSchema>;
|
||||||
const raw = {
|
|
||||||
|
let cached: AuthEnv | null = null;
|
||||||
|
|
||||||
|
function loadAuthEnv(): AuthEnv {
|
||||||
|
if (cached) return cached;
|
||||||
|
cached = envSchema.parse({
|
||||||
JWT_SECRET: process.env.JWT_SECRET,
|
JWT_SECRET: process.env.JWT_SECRET,
|
||||||
VK_CLIENT_ID: process.env.VK_CLIENT_ID,
|
VK_CLIENT_ID: process.env.VK_CLIENT_ID,
|
||||||
VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET,
|
VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET,
|
||||||
YANDEX_CLIENT_ID: process.env.YANDEX_CLIENT_ID,
|
YANDEX_CLIENT_ID: process.env.YANDEX_CLIENT_ID,
|
||||||
YANDEX_CLIENT_SECRET: process.env.YANDEX_CLIENT_SECRET,
|
YANDEX_CLIENT_SECRET: process.env.YANDEX_CLIENT_SECRET,
|
||||||
PUBLIC_APP_URL: process.env.PUBLIC_APP_URL,
|
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 {
|
export function requireEnv(name: keyof AuthEnv): string {
|
||||||
return authEnv[name];
|
return loadAuthEnv()[name];
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-3
@@ -2,18 +2,24 @@ import { SignJWT, jwtVerify } from 'jose';
|
|||||||
import type { APIContext } from 'astro';
|
import type { APIContext } from 'astro';
|
||||||
import { authEnv } from './env';
|
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> {
|
export async function createToken(userId: string): Promise<string> {
|
||||||
return new SignJWT({ sub: userId })
|
return new SignJWT({ sub: userId })
|
||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime('7d')
|
.setExpirationTime('7d')
|
||||||
.sign(SECRET);
|
.sign(getSecret());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyToken(token: string): Promise<{ sub: string }> {
|
export async function verifyToken(token: string): Promise<{ sub: string }> {
|
||||||
const { payload } = await jwtVerify(token, SECRET, {
|
const { payload } = await jwtVerify(token, getSecret(), {
|
||||||
clockTolerance: 60,
|
clockTolerance: 60,
|
||||||
});
|
});
|
||||||
if (!payload.sub || typeof payload.sub !== 'string') {
|
if (!payload.sub || typeof payload.sub !== 'string') {
|
||||||
|
|||||||
+15
-7
@@ -1,7 +1,13 @@
|
|||||||
import { SignJWT, jwtVerify } from 'jose';
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
import { authEnv } from './env';
|
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 {
|
export function generateCodeVerifier(): string {
|
||||||
const array = new Uint8Array(64);
|
const array = new Uint8Array(64);
|
||||||
@@ -30,17 +36,19 @@ export function generateState(): string {
|
|||||||
return base64UrlEncode(array);
|
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 = {
|
export const vkOAuthConfig = {
|
||||||
clientId: authEnv.VK_CLIENT_ID,
|
get clientId() { return authEnv.VK_CLIENT_ID; },
|
||||||
clientSecret: authEnv.VK_CLIENT_SECRET,
|
get clientSecret() { return authEnv.VK_CLIENT_SECRET; },
|
||||||
authUrl: 'https://id.vk.ru/authorize',
|
authUrl: 'https://id.vk.ru/authorize',
|
||||||
tokenUrl: 'https://id.vk.ru/oauth2/auth',
|
tokenUrl: 'https://id.vk.ru/oauth2/auth',
|
||||||
scope: 'email phone',
|
scope: 'email phone',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const yandexOAuthConfig = {
|
export const yandexOAuthConfig = {
|
||||||
clientId: authEnv.YANDEX_CLIENT_ID,
|
get clientId() { return authEnv.YANDEX_CLIENT_ID; },
|
||||||
clientSecret: authEnv.YANDEX_CLIENT_SECRET,
|
get clientSecret() { return authEnv.YANDEX_CLIENT_SECRET; },
|
||||||
authUrl: 'https://oauth.yandex.com/authorize',
|
authUrl: 'https://oauth.yandex.com/authorize',
|
||||||
tokenUrl: 'https://oauth.yandex.com/token',
|
tokenUrl: 'https://oauth.yandex.com/token',
|
||||||
scope: 'login:email login:info login:avatar',
|
scope: 'login:email login:info login:avatar',
|
||||||
@@ -51,12 +59,12 @@ export async function createSessionToken(userId: number): Promise<string> {
|
|||||||
.setProtectedHeader({ alg: 'HS256' })
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
.setIssuedAt()
|
.setIssuedAt()
|
||||||
.setExpirationTime('7d')
|
.setExpirationTime('7d')
|
||||||
.sign(JWT_SECRET);
|
.sign(getJwtSecret());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifySessionToken(token: string): Promise<{ userId: number } | null> {
|
export async function verifySessionToken(token: string): Promise<{ userId: number } | null> {
|
||||||
try {
|
try {
|
||||||
const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 });
|
const { payload } = await jwtVerify(token, getJwtSecret(), { clockTolerance: 60 });
|
||||||
if (!payload.sub) return null;
|
if (!payload.sub) return null;
|
||||||
return { userId: Number(payload.sub) };
|
return { userId: Number(payload.sub) };
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user