Move auth and API config from env vars to TOML (~/.arc/arc.toml)

Replace ARC_INSECURE_DISABLE_AUTHENTICATION and ARC_API_BASE_URL env vars
with [auth] and [api] sections in ~/.arc/arc.toml. Only secrets
(ARC_JWT_PUBLIC_KEY, ARC_JWT_PRIVATE_KEY) remain as env vars.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-02 22:49:04 -05:00
parent 5e337b4cb3
commit 1cebe29fad
9 changed files with 225 additions and 30 deletions

View file

@ -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=

View file

@ -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<Response> {
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,
});

View file

@ -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<string, unknown> = {};
try {
raw = parse(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
} catch {
// File doesn't exist or is unreadable — use defaults
}
const rawAuth = (raw.auth ?? {}) as Partial<AuthConfig>;
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
cached = {
auth: { ...AUTH_DEFAULTS, ...rawAuth },
api: { ...API_DEFAULTS, ...rawApi },
};
return cached;
}
/** Reset cached config (for testing). */
export function resetAppConfigCache(): void {
cached = null;
}

View file

@ -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);

View file

@ -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",

View file

@ -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=="],

View file

@ -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<String>,
}
#[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<PathBuf>,
#[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
);
}
}

View file

@ -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);

View file

@ -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);