mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
5e337b4cb3
commit
1cebe29fad
9 changed files with 225 additions and 30 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
59
apps/arc-web/app/lib/config.server.ts
Normal file
59
apps/arc-web/app/lib/config.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
3
bun.lock
3
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=="],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue