feat: Jan API Server should have API Key setting (#5193)

* feat: Jan API Server should have API Key setting

* chore: reveal api key icon

* Update src-tauri/src/core/server.rs

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: add validation apiKey

---------

Co-authored-by: Faisal Amir <urmauur@gmail.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Louis 2025-06-04 18:04:47 +07:00 committed by GitHub
parent da10502bdd
commit cb3ac4b136
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 178 additions and 10 deletions

View File

@ -1,8 +1,6 @@
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf};
use tauri::{AppHandle, Manager, Runtime, State};
use tauri_plugin_updater::UpdaterExt;
use super::{server, setup, state::AppState};
@ -347,10 +345,15 @@ pub async fn start_server(
host: String,
port: u16,
prefix: String,
api_key: String,
trusted_hosts: Vec<String>,
) -> Result<bool, String> {
let auth_token = app.state::<AppState>().app_token.clone().unwrap_or_default();
server::start_server(host, port, prefix, auth_token, trusted_hosts)
let auth_token = app
.state::<AppState>()
.app_token
.clone()
.unwrap_or_default();
server::start_server(host, port, prefix, auth_token, api_key, trusted_hosts)
.await
.map_err(|e| e.to_string())?;
Ok(true)

View File

@ -20,6 +20,7 @@ struct ProxyConfig {
prefix: String,
auth_token: String,
trusted_hosts: Vec<String>,
api_key: String,
}
/// Removes a prefix from a path, ensuring proper formatting
@ -86,6 +87,25 @@ async fn proxy_request(
.unwrap());
}
if !config.api_key.is_empty() {
if let Some(authorization) = req.headers().get(hyper::header::AUTHORIZATION) {
let auth_str = authorization.to_str().unwrap_or("");
if auth_str.strip_prefix("Bearer ") != Some(config.api_key.as_str())
{
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body(Body::from("Invalid or missing authorization token"))
.unwrap());
}
} else {
return Ok(Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body(Body::from("Missing authorization header"))
.unwrap());
}
}
// Block access to /configs endpoint
if path.contains("/configs") {
return Ok(Response::builder()
@ -102,8 +122,8 @@ async fn proxy_request(
// Copy original headers
for (name, value) in req.headers() {
if name != hyper::header::HOST {
// Skip host header
// Skip host & authorization header
if name != hyper::header::HOST && name != hyper::header::AUTHORIZATION {
outbound_req = outbound_req.header(name, value);
}
}
@ -152,14 +172,26 @@ fn is_valid_host(host: &str, trusted_hosts: &[String]) -> bool {
return false;
}
let host_without_port = if host.starts_with('[') { host.split(']').next().unwrap_or(host).trim_start_matches('[') } else { host.split(':').next().unwrap_or(host) };
let host_without_port = if host.starts_with('[') {
host.split(']')
.next()
.unwrap_or(host)
.trim_start_matches('[')
} else {
host.split(':').next().unwrap_or(host)
};
let default_valid_hosts = ["localhost", "127.0.0.1"];
if default_valid_hosts.iter().any(|&valid| host_without_port.to_lowercase() == valid.to_lowercase()) {
if default_valid_hosts
.iter()
.any(|&valid| host_without_port.to_lowercase() == valid.to_lowercase())
{
return true;
}
trusted_hosts.iter().any(|valid| host_without_port.to_lowercase() == valid.to_lowercase())
trusted_hosts
.iter()
.any(|valid| host_without_port.to_lowercase() == valid.to_lowercase())
}
/// Starts the proxy server
@ -168,6 +200,7 @@ pub async fn start_server(
port: u16,
prefix: String,
auth_token: String,
api_key: String,
trusted_hosts: Vec<String>,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
// Check if server is already running
@ -186,6 +219,7 @@ pub async fn start_server(
upstream: "http://127.0.0.1:39291".to_string(),
prefix,
auth_token,
api_key,
trusted_hosts,
};

View File

@ -0,0 +1,88 @@
import { Input } from '@/components/ui/input'
import { useLocalApiServer } from '@/hooks/useLocalApiServer'
import { useState, useEffect } from 'react'
import { Eye, EyeOff } from 'lucide-react'
interface ApiKeyInputProps {
showError?: boolean
onValidationChange?: (isValid: boolean) => void
}
export function ApiKeyInput({
showError = false,
onValidationChange,
}: ApiKeyInputProps) {
const { apiKey, setApiKey } = useLocalApiServer()
const [inputValue, setInputValue] = useState(apiKey.toString())
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const validateApiKey = (value: string) => {
if (!value || value.trim().length === 0) {
setError('API Key is required')
onValidationChange?.(false)
return false
}
setError('')
onValidationChange?.(true)
return true
}
useEffect(() => {
if (showError) {
validateApiKey(inputValue)
}
}, [showError, inputValue])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
// Clear error when user starts typing
if (error && value.trim().length > 0) {
setError('')
onValidationChange?.(true)
}
}
const handleBlur = () => {
setApiKey(inputValue)
// Validate on blur if showError is true
if (showError) {
validateApiKey(inputValue)
}
}
const hasError = error && showError
return (
<div className="relative w-full">
<Input
type={showPassword ? 'text' : 'password'}
value={inputValue}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full text-sm pr-10 ${
hasError
? 'border-1 border-destructive focus:border-destructive focus:ring-destructive'
: ''
}`}
placeholder="Enter API Key"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
<button
onClick={() => setShowPassword(!showPassword)}
className="p-1 rounded hover:bg-main-view-fg/5 text-main-view-fg/70"
type="button"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
{hasError && (
<p className="text-destructive text-xs mt-1 absolute -bottom-5 left-0">
{error}
</p>
)}
</div>
)
}

View File

@ -21,6 +21,8 @@ type LocalApiServerState = {
// Verbose server logs
verboseLogs: boolean
setVerboseLogs: (value: boolean) => void
apiKey: string
setApiKey: (value: string) => void
// Trusted hosts
trustedHosts: string[]
addTrustedHost: (host: string) => void
@ -53,6 +55,8 @@ export const useLocalApiServer = create<LocalApiServerState>()(
trustedHosts: state.trustedHosts.filter((h) => h !== host),
})),
setTrustedHosts: (hosts) => set({ trustedHosts: hosts }),
apiKey: '',
setApiKey: (value) => set({ apiKey: value }),
}),
{
name: localStorageKey.settingLocalApiServer,

View File

@ -16,6 +16,8 @@ import { useAppState } from '@/hooks/useAppState'
import { windowKey } from '@/constants/windows'
import { IconLogs } from '@tabler/icons-react'
import { cn } from '@/lib/utils'
import { ApiKeyInput } from '@/containers/ApiKeyInput'
import { useState } from 'react'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.settings.local_api_server as any)({
@ -32,12 +34,30 @@ function LocalAPIServer() {
serverHost,
serverPort,
apiPrefix,
apiKey,
trustedHosts,
} = useLocalApiServer()
const { serverStatus, setServerStatus } = useAppState()
const [showApiKeyError, setShowApiKeyError] = useState(false)
const [isApiKeyEmpty, setIsApiKeyEmpty] = useState(
!apiKey || apiKey.toString().trim().length === 0
)
const handleApiKeyValidation = (isValid: boolean) => {
setIsApiKeyEmpty(!isValid)
}
const toggleAPIServer = async () => {
// Validate API key before starting server
if (serverStatus === 'stopped') {
if (!apiKey || apiKey.toString().trim().length === 0) {
setShowApiKeyError(true)
return
}
setShowApiKeyError(false)
}
setServerStatus('pending')
if (serverStatus === 'stopped') {
window.core?.api
@ -45,6 +65,7 @@ function LocalAPIServer() {
host: serverHost,
port: serverPort,
prefix: apiPrefix,
apiKey,
trustedHosts,
isCorsEnabled: corsEnabled,
isVerboseEnabled: verboseLogs,
@ -52,6 +73,10 @@ function LocalAPIServer() {
.then(() => {
setServerStatus('running')
})
.catch((error: unknown) => {
console.error('Error starting server:', error)
setServerStatus('stopped')
})
} else {
window.core?.api
?.stopServer()
@ -182,6 +207,20 @@ function LocalAPIServer() {
)}
actions={<ApiPrefixInput />}
/>
<CardItem
title="API Key"
description="Authenticate requests with an API key"
className={cn(
isServerRunning && 'opacity-50 pointer-events-none',
isApiKeyEmpty && showApiKeyError && 'pb-6'
)}
actions={
<ApiKeyInput
showError={showApiKeyError}
onValidationChange={handleApiKeyValidation}
/>
}
/>
<CardItem
title="Trusted Hosts"
description="Add trusted hosts that can access the API server"