diff --git a/.env.example b/.env.example index a15540478..d2576ff94 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,6 @@ export ZAI_API_KEY= export ARC_JWT_PRIVATE_KEY= export ARC_JWT_PUBLIC_KEY= -export ARC_API_BASE_URL= -export ARC_INSECURE_DISABLE_AUTHENTICATION= export SESSION_SECRET= export GITHUB_APP_ID= diff --git a/apps/arc-web/app/api-client.ts b/apps/arc-web/app/api-client.ts index c20fa90d6..3808c99d5 100644 --- a/apps/arc-web/app/api-client.ts +++ b/apps/arc-web/app/api-client.ts @@ -1,6 +1,6 @@ import { importPKCS8, SignJWT } from "jose"; +import { getAppConfig } from "./lib/config.server"; -const ARC_API_BASE_URL = process.env.ARC_API_BASE_URL ?? "http://localhost:3000"; const ARC_JWT_PRIVATE_KEY = process.env.ARC_JWT_PRIVATE_KEY; let cachedKey: CryptoKey | null = null; @@ -30,9 +30,7 @@ export async function apiFetch( path: string, init?: RequestInit ): Promise { - if (!ARC_API_BASE_URL) { - throw new Error("ARC_API_BASE_URL environment variable is not set"); - } + const { base_url } = getAppConfig().api; const headers = new Headers(init?.headers); if (ARC_JWT_PRIVATE_KEY) { @@ -40,7 +38,7 @@ export async function apiFetch( headers.set("Authorization", `Bearer ${token}`); } - return fetch(`${ARC_API_BASE_URL}${path}`, { + return fetch(`${base_url}${path}`, { ...init, headers, }); diff --git a/apps/arc-web/app/lib/config.server.ts b/apps/arc-web/app/lib/config.server.ts new file mode 100644 index 000000000..9287d7143 --- /dev/null +++ b/apps/arc-web/app/lib/config.server.ts @@ -0,0 +1,59 @@ +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { parse } from "smol-toml"; + +interface AuthConfig { + provider: "github" | "insecure_disabled"; + allowed_usernames: string[]; +} + +interface ApiConfig { + base_url: string; + authentication_strategy: "jwt" | "insecure_disabled"; +} + +export interface AppConfig { + auth: AuthConfig; + api: ApiConfig; +} + +const AUTH_DEFAULTS: AuthConfig = { + provider: "github", + allowed_usernames: [], +}; + +const API_DEFAULTS: ApiConfig = { + base_url: "http://localhost:3000", + authentication_strategy: "jwt", +}; + +let cached: AppConfig | null = null; + +export function getAppConfig(): AppConfig { + if (cached) return cached; + + const configPath = join(homedir(), ".arc", "arc.toml"); + + let raw: Record = {}; + try { + raw = parse(readFileSync(configPath, "utf-8")) as Record; + } catch { + // File doesn't exist or is unreadable — use defaults + } + + const rawAuth = (raw.auth ?? {}) as Partial; + const rawApi = (raw.api ?? {}) as Partial; + + cached = { + auth: { ...AUTH_DEFAULTS, ...rawAuth }, + api: { ...API_DEFAULTS, ...rawApi }, + }; + + return cached; +} + +/** Reset cached config (for testing). */ +export function resetAppConfigCache(): void { + cached = null; +} diff --git a/apps/arc-web/app/routes/auth-callback.tsx b/apps/arc-web/app/routes/auth-callback.tsx index 4bc4b54ef..a09395108 100644 --- a/apps/arc-web/app/routes/auth-callback.tsx +++ b/apps/arc-web/app/routes/auth-callback.tsx @@ -1,4 +1,5 @@ import { redirect } from "react-router"; +import { getAppConfig } from "../lib/config.server"; import { getGitHubOAuth } from "../lib/github.server"; import { getSession, commitSession } from "../lib/session.server"; import type { Route } from "./+types/auth-callback"; @@ -42,6 +43,11 @@ export async function loader({ request }: Route.LoaderArgs) { }>; const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? ""; + const { allowed_usernames } = getAppConfig().auth; + if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) { + throw redirect("/auth/login?error=unauthorized"); + } + const session = await getSession(request); session.set("userUrl", `https://api.github.com/user/${profile.id}`); session.set("githubId", profile.id); diff --git a/apps/arc-web/package.json b/apps/arc-web/package.json index 989593e16..c134255c5 100644 --- a/apps/arc-web/package.json +++ b/apps/arc-web/package.json @@ -6,7 +6,7 @@ "build": "react-router build", "dev": "react-router dev", "start": "react-router-serve ./build/server/index.js", - "test": "ARC_API_BASE_URL=http://localhost:9999 bun test", + "test": "bun test", "typecheck": "react-router typegen && tsc" }, "dependencies": { @@ -26,7 +26,8 @@ "jose": "^6.1.3", "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router": "7.12.0" + "react-router": "7.12.0", + "smol-toml": "^1.6.0" }, "devDependencies": { "@react-router/dev": "7.12.0", diff --git a/bun.lock b/bun.lock index 38c873fe2..d33f51da8 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-router": "7.12.0", + "smol-toml": "^1.6.0", }, "devDependencies": { "@react-router/dev": "7.12.0", @@ -739,6 +740,8 @@ "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], diff --git a/crates/arc-api/src/app_config.rs b/crates/arc-api/src/app_config.rs index 445b65d1d..f1d800b58 100644 --- a/crates/arc-api/src/app_config.rs +++ b/crates/arc-api/src/app_config.rs @@ -2,9 +2,68 @@ use std::path::PathBuf; use serde::Deserialize; +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AuthProvider { + Github, + InsecureDisabled, +} + +impl Default for AuthProvider { + fn default() -> Self { + Self::Github + } +} + +#[derive(Debug, Clone, Default, Deserialize, PartialEq)] +pub struct AuthConfig { + #[serde(default)] + pub provider: AuthProvider, + #[serde(default)] + pub allowed_usernames: Vec, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ApiAuthenticationStrategy { + Jwt, + InsecureDisabled, +} + +impl Default for ApiAuthenticationStrategy { + fn default() -> Self { + Self::Jwt + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct ApiConfig { + #[serde(default = "default_base_url")] + pub base_url: String, + #[serde(default)] + pub authentication_strategy: ApiAuthenticationStrategy, +} + +fn default_base_url() -> String { + "http://localhost:3000".to_string() +} + +impl Default for ApiConfig { + fn default() -> Self { + Self { + base_url: default_base_url(), + authentication_strategy: ApiAuthenticationStrategy::default(), + } + } +} + #[derive(Debug, Default, Deserialize)] pub struct AppConfig { pub data_dir: Option, + #[serde(default)] + pub auth: AuthConfig, + #[serde(default)] + pub api: ApiConfig, } /// Load app config from `~/.arc/arc.toml`, returning defaults if the file doesn't exist. @@ -53,6 +112,7 @@ mod tests { fn resolve_data_dir_uses_config_value() { let config = AppConfig { data_dir: Some(PathBuf::from("/my/data")), + ..AppConfig::default() }; assert_eq!(resolve_data_dir(&config), PathBuf::from("/my/data")); } @@ -68,4 +128,61 @@ mod tests { dir.display() ); } + + #[test] + fn parse_full_config() { + let toml = r#" +[auth] +provider = "github" +allowed_usernames = ["brynary", "alice"] + +[api] +base_url = "http://example.com:8080" +authentication_strategy = "jwt" +"#; + let config: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::Github); + assert_eq!(config.auth.allowed_usernames, vec!["brynary", "alice"]); + assert_eq!(config.api.base_url, "http://example.com:8080"); + assert_eq!( + config.api.authentication_strategy, + ApiAuthenticationStrategy::Jwt + ); + } + + #[test] + fn parse_auth_defaults() { + let toml = ""; + let config: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::Github); + assert!(config.auth.allowed_usernames.is_empty()); + } + + #[test] + fn parse_api_defaults() { + let toml = ""; + let config: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.api.base_url, "http://localhost:3000"); + assert_eq!( + config.api.authentication_strategy, + ApiAuthenticationStrategy::Jwt + ); + } + + #[test] + fn parse_insecure_disabled_values() { + let toml = r#" +[auth] +provider = "insecure_disabled" + +[api] +authentication_strategy = "insecure_disabled" +"#; + let config: AppConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.auth.provider, AuthProvider::InsecureDisabled); + assert_eq!( + config.api.authentication_strategy, + ApiAuthenticationStrategy::InsecureDisabled + ); + } } diff --git a/crates/arc-api/src/jwt_auth.rs b/crates/arc-api/src/jwt_auth.rs index 48afd2e08..ae1b649f3 100644 --- a/crates/arc-api/src/jwt_auth.rs +++ b/crates/arc-api/src/jwt_auth.rs @@ -26,28 +26,30 @@ pub enum AuthMode { Disabled, } -/// Resolve the authentication mode from environment variables. +/// Resolve the authentication mode from the API config section. /// /// Call this once at startup before serving requests. Panics if the -/// configuration is invalid (no public key and insecure mode not enabled). -pub fn resolve_auth_mode() -> AuthMode { - if let Ok(pem) = std::env::var("ARC_JWT_PUBLIC_KEY") { - let key = DecodingKey::from_ed_pem(pem.as_bytes()) - .expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); - AuthMode::Jwt(Arc::new(key)) - } else if std::env::var("ARC_INSECURE_DISABLE_AUTHENTICATION") - .ok() - .as_deref() - == Some("true") - { - warn!("JWT authentication disabled"); - AuthMode::Disabled - } else { - panic!( - "ARC_JWT_PUBLIC_KEY is not set. Either provide an Ed25519 public key in PEM \ - format or set ARC_INSECURE_DISABLE_AUTHENTICATION=true to allow \ - unauthenticated access (development only)." - ); +/// configuration is invalid (JWT strategy but no public key). +pub fn resolve_auth_mode(api_config: &crate::app_config::ApiConfig) -> AuthMode { + use crate::app_config::ApiAuthenticationStrategy; + + match api_config.authentication_strategy { + ApiAuthenticationStrategy::InsecureDisabled => { + warn!("JWT authentication disabled"); + AuthMode::Disabled + } + ApiAuthenticationStrategy::Jwt => { + let pem = std::env::var("ARC_JWT_PUBLIC_KEY").unwrap_or_else(|_| { + panic!( + "ARC_JWT_PUBLIC_KEY is not set. Either provide an Ed25519 public key in PEM \ + format or set authentication_strategy = \"insecure_disabled\" in \ + ~/.arc/arc.toml to allow unauthenticated access (development only)." + ) + }); + let key = DecodingKey::from_ed_pem(pem.as_bytes()) + .expect("ARC_JWT_PUBLIC_KEY contains an invalid Ed25519 PEM public key"); + AuthMode::Jwt(Arc::new(key)) + } } } @@ -244,6 +246,17 @@ mod tests { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } + #[test] + fn resolve_auth_mode_insecure_disabled() { + use crate::app_config::{ApiAuthenticationStrategy, ApiConfig}; + + let config = ApiConfig { + authentication_strategy: ApiAuthenticationStrategy::InsecureDisabled, + ..ApiConfig::default() + }; + assert!(matches!(resolve_auth_mode(&config), AuthMode::Disabled)); + } + #[tokio::test] async fn disabled_mode_allows_all_requests() { let app = test_router(AuthMode::Disabled); diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index bf848faf2..4c21ac38f 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -123,7 +123,7 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: let auth_mode = if args.demo { crate::jwt_auth::AuthMode::Disabled } else { - crate::jwt_auth::resolve_auth_mode() + crate::jwt_auth::resolve_auth_mode(&app_config.api) }; let state = create_app_state_with_options(db, factory, dry_run_mode, args.demo);