use axum::http::header::SET_COOKIE; use axum::http::{HeaderMap, HeaderValue}; use axum::response::{IntoResponse, Redirect, Response}; use serde::{Deserialize, Serialize}; use config::oauth::OAuthConfig; use crate::errors::AuthError; use crate::services::AuthService; pub mod callback; pub mod login; pub mod logout; pub mod project; pub mod refresh; pub mod revalidate; pub mod state; pub const STATE_TTL_SECS: u64 = 600; pub const REFRESH_LOCK_TTL_SECS: u64 = 10; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoginState { pub redirect: String, pub verifier: String, pub nonce: String, /// 本次握手实际发给 KenOAuth 的 redirect_uri。 /// /// 换码时必须原样回传(OAuth 要求两者完全一致),所以随 state 一起存。 /// `#[serde(default)]` 保证旧 state 仍能解析。 #[serde(default)] pub redirect_uri: Option, } #[derive(Debug, Deserialize)] pub struct TokenResponse { pub access_token: String, #[serde(default)] pub refresh_token: Option, #[serde(default)] pub id_token: Option, #[serde(default)] pub expires_in: i64, } pub fn random_token(bytes: usize) -> String { use base64::Engine as _; let mut buffer = vec![0u8; bytes]; rand::Rng::fill(&mut rand::thread_rng(), buffer.as_mut_slice()); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buffer) } pub fn pkce_challenge(verifier: &str) -> String { use base64::Engine as _; use sha2::{Digest, Sha256}; let digest = Sha256::digest(verifier.as_bytes()); base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest) } pub fn read_cookie_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { let value = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; jwt::read_cookie(value, name) } pub fn append_cookie(response: &mut Response, cookie: &str) { if let Ok(value) = HeaderValue::from_str(cookie) { response.headers_mut().append(SET_COOKIE, value); } } pub fn redirect_with_cookies( location: &str, cookies: &[String], ) -> Response { let mut response = Redirect::to(location).into_response(); for cookie in cookies { append_cookie(&mut response, cookie); } response } pub async fn exchange_code( service: &AuthService, code: &str, verifier: &str, redirect_uri: &str, ) -> Result { let config = &service.config().oauth; let form = [ ("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri), ("client_id", config.client_id.as_str()), ("code_verifier", verifier), ]; let response = reqwest::Client::new() .post(config.token_endpoint()) .form(&form) .send() .await .map_err(|error| AuthError::Internal(format!("token endpoint request failed: {error}")))?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(AuthError::BadRequest(format!( "token endpoint rejected the grant ({status}): {body}" ))); } response .json::() .await .map_err(|error| AuthError::Internal(format!("invalid token response: {error}"))) } pub async fn refresh_grant( service: &AuthService, refresh_token: &str, ) -> Result { let config = &service.config().oauth; let form = [ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", config.client_id.as_str()), ]; let response = reqwest::Client::new() .post(config.token_endpoint()) .form(&form) .send() .await .map_err(|error| AuthError::Internal(format!("token endpoint request failed: {error}")))?; if !response.status().is_success() { let body = response.text().await.unwrap_or_default(); return Err(AuthError::BadRequest(format!( "refresh token rejected: {body}" ))); } response .json::() .await .map_err(|error| AuthError::Internal(format!("invalid token response: {error}"))) } /// 本次请求应该用哪个 `redirect_uri`。 /// /// 取 `x-forwarded-host`(vite / 网关都会带),退化为 `host`; /// 只有命中 `oauth.redirect_uri_hosts` 白名单才按该主机改写,路径沿用配置里的。 pub fn resolve_redirect_uri(config: &OAuthConfig, headers: &HeaderMap) -> String { let host = headers .get("x-forwarded-host") .and_then(|value| value.to_str().ok()) .or_else(|| { headers .get(axum::http::header::HOST) .and_then(|value| value.to_str().ok()) }) // 代理链可能给逗号分隔的列表,取第一跳。 .map(|value| value.split(',').next().unwrap_or(value).trim().to_ascii_lowercase()); let Some(host) = host else { return config.redirect_uri.clone(); }; if host.is_empty() || !config .redirect_uri_hosts .iter() .any(|allowed| allowed.trim().eq_ignore_ascii_case(&host)) { return config.redirect_uri.clone(); } let path = url::Url::parse(&config.redirect_uri) .map(|url| url.path().to_string()) .unwrap_or_else(|_| "/api/v1/auth/callback".to_string()); format!("http://{host}{path}") }