Files
Randify.pro/src/lib/auth/oauth.ts
T
emilandClaude Opus 4.7 4b78e9edad 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>
2026-05-15 23:45:03 +03:00

83 lines
2.6 KiB
TypeScript

import { SignJWT, jwtVerify } from 'jose';
import { authEnv } from './env';
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);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
function base64UrlEncode(buffer: Uint8Array): string {
let binary = '';
for (let i = 0; i < buffer.byteLength; i++) {
binary += String.fromCharCode(buffer[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(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 = {
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 = {
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',
};
export async function createSessionToken(userId: number): Promise<string> {
return new SignJWT({ sub: String(userId) })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(getJwtSecret());
}
export async function verifySessionToken(token: string): Promise<{ userId: number } | null> {
try {
const { payload } = await jwtVerify(token, getJwtSecret(), { clockTolerance: 60 });
if (!payload.sub) return null;
return { userId: Number(payload.sub) };
} catch {
return null;
}
}
export const COOKIE_NAME = 'auth_token';
export const VERIFIER_COOKIE_NAME = 'oauth_verifier';
export function getCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null;
const match = cookieHeader.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
return match ? decodeURIComponent(match[1]) : null;
}