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, 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(()) } }