diff --git a/site/astro.config.mjs b/site/astro.config.mjs index e04a91bc0..0b3a8f4d2 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -8,7 +8,7 @@ import react from '@astrojs/react'; import sitemap from '@astrojs/sitemap'; import sentry from '@sentry/astro'; import tailwindcss from '@tailwindcss/vite'; -import { defineConfig, fontProviders } from 'astro/config'; +import { defineConfig, envField, fontProviders } from 'astro/config'; import checkV8Urls from './integrations/check-v8-urls'; import llmsMarkdown from './integrations/llms-markdown'; import pagefind from './integrations/pagefind'; @@ -24,6 +24,28 @@ export default defineConfig({ site: SITE_URL, trailingSlash: 'never', adapter: netlify(), + // Server-only secrets read at runtime (not inlined at build time). + // All optional — the site degrades gracefully without auth/Mux configured. + // See site/CLAUDE.md "Environment Variables" for full documentation. + env: { + schema: { + // OAuth — powers the video uploader login flow + OAUTH_CLIENT_ID: envField.string({ context: 'server', access: 'secret', optional: true }), + OAUTH_CLIENT_SECRET: envField.string({ context: 'server', access: 'secret', optional: true }), + OAUTH_REDIRECT_URI: envField.string({ context: 'server', access: 'secret', optional: true }), + OAUTH_URL: envField.string({ context: 'server', access: 'secret', optional: true }), + SESSION_COOKIE_PASSWORD: envField.string({ context: 'server', access: 'secret', optional: true }), + MUX_API_URL: envField.string({ + context: 'server', + access: 'secret', + optional: true, + default: 'https://api.mux.com', + }), + // Mux service account credentials — only used by the /api/health/mux endpoint + MUX_TOKEN_ID: envField.string({ context: 'server', access: 'secret', optional: true }), + MUX_TOKEN_SECRET: envField.string({ context: 'server', access: 'secret', optional: true }), + }, + }, redirects: { // Redirects are configured in netlify.toml }, diff --git a/site/src/actions/auth.ts b/site/src/actions/auth.ts index b2b0df648..6b50a0529 100644 --- a/site/src/actions/auth.ts +++ b/site/src/actions/auth.ts @@ -1,9 +1,8 @@ import { ActionError, defineAction } from 'astro:actions'; +import { OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI, OAUTH_URL } from 'astro:env/server'; import { z } from 'astro:schema'; import { SESSION_COOKIE_NAME } from '@/utils/auth'; -const { OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI, OAUTH_URL, PROD } = import.meta.env; - export const auth = { /** * Initiates the OAuth 2.0 login flow @@ -39,7 +38,7 @@ export const auth = { // Store state in a short-lived cookie for verification in the callback ctx.cookies.set('state', state, { httpOnly: true, - secure: PROD, + secure: import.meta.env.PROD, sameSite: 'lax', maxAge: 600, // Expires after 10 minutes path: '/', diff --git a/site/src/actions/mux.ts b/site/src/actions/mux.ts index 8c5f101b7..b677dba51 100644 --- a/site/src/actions/mux.ts +++ b/site/src/actions/mux.ts @@ -1,4 +1,5 @@ import { ActionError, defineAction } from 'astro:actions'; +import { MUX_API_URL, MUX_TOKEN_ID, MUX_TOKEN_SECRET } from 'astro:env/server'; import { z } from 'astro:schema'; import Mux from '@mux/mux-node'; @@ -18,13 +19,13 @@ function getMuxClient(token: string | undefined) { return new Mux({ authorizationToken: token, - baseURL: import.meta.env.MUX_API_URL ?? 'https://api.mux.com', + baseURL: MUX_API_URL, }); } function getHealthMuxClient() { - const tokenId = process.env.MUX_TOKEN_ID || import.meta.env.MUX_TOKEN_ID; - const tokenSecret = process.env.MUX_TOKEN_SECRET || import.meta.env.MUX_TOKEN_SECRET; + const tokenId = MUX_TOKEN_ID; + const tokenSecret = MUX_TOKEN_SECRET; if (!tokenId || !tokenSecret) { throw new ActionError({ diff --git a/site/src/pages/api/auth/__tests__/callback.test.ts b/site/src/pages/api/auth/__tests__/callback.test.ts index 0209f18b7..a7d96b759 100644 --- a/site/src/pages/api/auth/__tests__/callback.test.ts +++ b/site/src/pages/api/auth/__tests__/callback.test.ts @@ -2,8 +2,28 @@ import type { APIContext } from 'astro'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { GET as callbackHandler } from '@/pages/api/auth/callback'; -import { exchangeAuthorizationCode, seal } from '@/utils/auth'; + +const env = vi.hoisted(() => ({ + OAUTH_CLIENT_ID: 'test-client-id' as string | undefined, + OAUTH_CLIENT_SECRET: 'test-client-secret' as string | undefined, + OAUTH_REDIRECT_URI: 'https://example.com/callback' as string | undefined, + OAUTH_URL: 'https://auth.example.com' as string | undefined, +})); + +vi.mock('astro:env/server', () => ({ + get OAUTH_CLIENT_ID() { + return env.OAUTH_CLIENT_ID; + }, + get OAUTH_CLIENT_SECRET() { + return env.OAUTH_CLIENT_SECRET; + }, + get OAUTH_REDIRECT_URI() { + return env.OAUTH_REDIRECT_URI; + }, + get OAUTH_URL() { + return env.OAUTH_URL; + }, +})); vi.mock('@/utils/auth', () => ({ exchangeAuthorizationCode: vi.fn(), @@ -12,6 +32,9 @@ vi.mock('@/utils/auth', () => ({ INACTIVITY_EXPIRY: 300, })); +import { GET as callbackHandler } from '@/pages/api/auth/callback'; +import { exchangeAuthorizationCode, seal } from '@/utils/auth'; + const mockOAuthResponse = { access_token: 'mock-access-token', refresh_token: 'mock-refresh-token', @@ -49,16 +72,16 @@ describe('callback endpoint', () => { vi.mocked(exchangeAuthorizationCode).mockResolvedValue(mockOAuthResponse); vi.mocked(seal).mockResolvedValue('encrypted-session-data'); - vi.stubEnv('OAUTH_CLIENT_ID', 'test-client-id'); - vi.stubEnv('OAUTH_CLIENT_SECRET', 'test-client-secret'); - vi.stubEnv('OAUTH_REDIRECT_URI', 'https://example.com/callback'); - vi.stubEnv('OAUTH_URL', 'https://auth.example.com'); - vi.stubEnv('PROD', false); + env.OAUTH_CLIENT_ID = 'test-client-id'; + env.OAUTH_CLIENT_SECRET = 'test-client-secret'; + env.OAUTH_REDIRECT_URI = 'https://example.com/callback'; + env.OAUTH_URL = 'https://auth.example.com'; consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { + consoleErrorSpy.mockRestore(); vi.unstubAllEnvs(); }); @@ -110,7 +133,7 @@ describe('callback endpoint', () => { 'OAUTH_CLIENT_ID', 'OAUTH_CLIENT_SECRET', ])('should redirect to error when %s is missing', async (envVar) => { - vi.stubEnv(envVar, undefined); + env[envVar as keyof typeof env] = undefined; const mockContext = createMockContext({ code: '123', state: 'abc', storedState: 'abc' }); await callbackHandler(mockContext); diff --git a/site/src/pages/api/auth/callback.ts b/site/src/pages/api/auth/callback.ts index d0bd275d2..44b84f1a0 100644 --- a/site/src/pages/api/auth/callback.ts +++ b/site/src/pages/api/auth/callback.ts @@ -1,3 +1,4 @@ +import { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_URL } from 'astro:env/server'; import type { APIRoute } from 'astro'; import { exchangeAuthorizationCode, INACTIVITY_EXPIRY, SESSION_COOKIE_NAME, seal } from '@/utils/auth'; @@ -15,8 +16,6 @@ export const prerender = false; * 4. Redirects to success or error page */ export const GET: APIRoute = async ({ request, cookies, redirect }) => { - const { OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, OAUTH_URL } = import.meta.env; - const url = new URL(request.url); const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); diff --git a/site/src/utils/auth.ts b/site/src/utils/auth.ts index 4b0eb7675..856230663 100644 --- a/site/src/utils/auth.ts +++ b/site/src/utils/auth.ts @@ -1,9 +1,13 @@ +import { + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, + OAUTH_REDIRECT_URI, + OAUTH_URL, + SESSION_COOKIE_PASSWORD, +} from 'astro:env/server'; import { sealData, unsealData } from 'iron-session'; import { createRemoteJWKSet } from 'jose'; -const { OAUTH_URL, OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI, SESSION_COOKIE_PASSWORD } = import.meta - .env; - // ============================================================================= // Types // =============================================================================