b453d5d
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<String>,
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub id_token: Option<String>,
#[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<TokenResponse, AuthError> {
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::<TokenResponse>()
.await
.map_err(|error| AuthError::Internal(format!("invalid token response: {error}")))
}
pub async fn refresh_grant(
service: &AuthService,
refresh_token: &str,
) -> Result<TokenResponse, AuthError> {
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::<TokenResponse>()
.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}")
}