Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
},
Expand Down
5 changes: 2 additions & 3 deletions site/src/actions/auth.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: '/',
Expand Down
7 changes: 4 additions & 3 deletions site/src/actions/mux.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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({
Expand Down
39 changes: 31 additions & 8 deletions site/src/pages/api/auth/__tests__/callback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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',
Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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);
Expand Down
3 changes: 1 addition & 2 deletions site/src/pages/api/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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');
Expand Down
10 changes: 7 additions & 3 deletions site/src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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
// =============================================================================
Expand Down