b453d5d
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SameSite {
Strict,
#[default]
Lax,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OAuthConfig {
pub enabled: bool,
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
/// 允许按请求 Host 切换回调地址的白名单(`host:port`)。
///
/// 本机同时存在两套前端:vite dev(5173)和网关托管的构建产物(7041)。
/// 命中这里的主机时,`redirect_uri` 会换成 `http://<该主机>/api/v1/auth/callback`,
/// 于是两套前端都能各自登录;未命中时回退到 `redirect_uri`。
#[serde(default)]
pub redirect_uri_hosts: Vec<String>,
pub token_endpoint: String,
pub jwks_url: String,
pub audience: String,
pub grpc_endpoint: String,
pub rpc_key: String,
pub session_cookie: String,
pub refresh_cookie: String,
pub cookie_domain: String,
pub cookie_path: String,
pub cookie_secure: bool,
pub cookie_same_site: SameSite,
pub access_ttl_secs: i64,
pub refresh_cookie_max_age_secs: i64,
pub recheck_secs: u64,
pub leeway_secs: u64,
pub jwks_cache_secs: u64,
pub jwks_min_refresh_secs: u64,
pub http_timeout_ms: u64,
pub chat_audience: String,
}
impl Default for OAuthConfig {
fn default() -> Self {
Self {
enabled: false,
issuer: String::new(),
client_id: "gitdata-web".to_string(),
client_secret: String::new(),
redirect_uri: String::new(),
redirect_uri_hosts: Vec::new(),
token_endpoint: String::new(),
jwks_url: String::new(),
audience: "gitdata.ai".to_string(),
grpc_endpoint: String::new(),
rpc_key: String::new(),
session_cookie: "gitdata_session".to_string(),
refresh_cookie: "gitdata_refresh".to_string(),
cookie_domain: String::new(),
cookie_path: "/".to_string(),
cookie_secure: false,
cookie_same_site: SameSite::Lax,
access_ttl_secs: 600,
refresh_cookie_max_age_secs: 2_592_000,
recheck_secs: 60,
leeway_secs: 30,
jwks_cache_secs: 3600,
jwks_min_refresh_secs: 30,
http_timeout_ms: 2000,
chat_audience: "chat.gitdata.ai".to_string(),
}
}
}
impl OAuthConfig {
pub fn token_endpoint(&self) -> String {
if !self.token_endpoint.is_empty() {
return self.token_endpoint.clone();
}
format!("{}/api/v1/oauth/token", self.issuer.trim_end_matches('/'))
}
pub fn jwks_url(&self) -> String {
if !self.jwks_url.is_empty() {
return self.jwks_url.clone();
}
format!(
"{}/.well-known/jwks.json",
self.issuer.trim_end_matches('/')
)
}
pub fn authorization_endpoint(&self) -> String {
format!("{}/api/v1/oauth/authorize", self.issuer.trim_end_matches('/'))
}
pub fn end_session_endpoint(&self) -> String {
format!("{}/api/v1/oauth/logout", self.issuer.trim_end_matches('/'))
}
pub fn is_valid(&self) -> Result<(), String> {
if !self.enabled {
return Ok(());
}
if self.issuer.is_empty() {
return Err("oauth.issuer is required".to_string());
}
if !self.issuer.starts_with("http://") && !self.issuer.starts_with("https://") {
return Err(format!("oauth.issuer must be an http(s) URL: {:?}", self.issuer));
}
if self.client_id.is_empty() {
return Err("oauth.client_id is required".to_string());
}
if self.redirect_uri.is_empty() {
return Err("oauth.redirect_uri is required".to_string());
}
if self.session_cookie.is_empty() || self.refresh_cookie.is_empty() {
return Err("oauth cookie names must not be empty".to_string());
}
if self.session_cookie == self.refresh_cookie {
return Err("oauth session and refresh cookies must differ".to_string());
}
if self.access_ttl_secs < 60 {
return Err("oauth.access_ttl_secs must be at least 60".to_string());
}
if self.refresh_cookie_max_age_secs < self.access_ttl_secs {
return Err(
"oauth.refresh_cookie_max_age_secs must not be shorter than access_ttl_secs"
.to_string(),
);
}
if !self.grpc_endpoint.is_empty() {
if !self.grpc_endpoint.starts_with("http://")
&& !self.grpc_endpoint.starts_with("https://")
{
return Err(format!(
"oauth.grpc_endpoint must be an http(s) URL: {:?}",
self.grpc_endpoint
));
}
if !self.rpc_key.is_empty() && self.rpc_key.len() < 32 {
return Err("oauth.rpc_key must be at least 32 characters".to_string());
}
}
Ok(())
}
}