feat: add refresh cookie reader

This commit is contained in:
2026-04-21 13:34:54 +08:00
parent adaf514a1a
commit 39eb7a513c
12 changed files with 653 additions and 11 deletions

View File

@@ -3,7 +3,10 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T
use tracing::{Level, info_span};
use crate::{
auth::{inspect_auth_claims, require_bearer_auth},
auth::{
attach_refresh_session_token, inspect_auth_claims, inspect_refresh_session_cookie,
require_bearer_auth,
},
error_middleware::normalize_error_response,
health::health_check,
request_context::{attach_request_context, resolve_request_id},
@@ -27,6 +30,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/_internal/auth/refresh-cookie",
get(inspect_refresh_session_cookie).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
.layer(middleware::from_fn(normalize_error_response))
// 响应头回写放在错误归一化外侧,确保最终写回的是归一化后的最终响应。
@@ -239,4 +249,72 @@ mod tests {
Value::Number(serde_json::Number::from(7))
);
}
#[tokio::test]
async fn internal_refresh_cookie_reports_missing_cookie() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/refresh-cookie")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["present"], Value::Bool(false));
assert_eq!(
payload["cookieName"],
Value::String("genarrative_refresh_session".to_string())
);
}
#[tokio::test]
async fn internal_refresh_cookie_reports_present_cookie() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/_internal/auth/refresh-cookie")
.header(
"cookie",
"theme=dark; genarrative_refresh_session=token12345",
)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["present"], Value::Bool(true));
assert_eq!(
payload["tokenLength"],
Value::Number(serde_json::Number::from(10))
);
}
}

View File

@@ -1,11 +1,14 @@
use axum::{
Json,
extract::{Extension, Request, State},
http::{HeaderMap, StatusCode, header::AUTHORIZATION},
http::{
HeaderMap, StatusCode,
header::{AUTHORIZATION, COOKIE},
},
middleware::Next,
response::Response,
};
use platform_auth::{AccessTokenClaims, verify_access_token};
use platform_auth::{AccessTokenClaims, read_refresh_session_token, verify_access_token};
use serde_json::{Value, json};
use tracing::warn;
@@ -20,6 +23,11 @@ pub struct AuthenticatedAccessToken {
claims: AccessTokenClaims,
}
#[derive(Clone, Debug)]
pub struct RefreshSessionToken {
token: String,
}
impl AuthenticatedAccessToken {
pub fn new(claims: AccessTokenClaims) -> Self {
Self { claims }
@@ -30,6 +38,16 @@ impl AuthenticatedAccessToken {
}
}
impl RefreshSessionToken {
pub fn new(token: String) -> Self {
Self { token }
}
pub fn token(&self) -> &str {
&self.token
}
}
pub async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
@@ -69,6 +87,44 @@ pub async fn inspect_auth_claims(
)
}
pub async fn attach_refresh_session_token(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
if let Some(token) = request
.headers()
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|cookie_header| {
read_refresh_session_token(cookie_header, state.refresh_cookie_config())
})
{
request
.extensions_mut()
.insert(RefreshSessionToken::new(token));
}
next.run(request).await
}
pub async fn inspect_refresh_session_cookie(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
request: Request,
) -> Json<Value> {
let maybe_token = request.extensions().get::<RefreshSessionToken>();
json_success_body(
Some(&request_context),
json!({
"cookieName": state.refresh_cookie_config().cookie_name(),
"present": maybe_token.is_some(),
"tokenLength": maybe_token.map(|token| token.token().len()),
}),
)
}
fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
let authorization = headers
.get(AUTHORIZATION)
@@ -88,7 +144,7 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result<String, AppError> {
#[cfg(test)]
mod tests {
use super::extract_bearer_token;
use super::{RefreshSessionToken, extract_bearer_token};
use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header::AUTHORIZATION},
response::IntoResponse,
@@ -116,4 +172,11 @@ mod tests {
assert_eq!(error.into_response().status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn refresh_session_token_retains_original_value() {
let token = RefreshSessionToken::new("refresh-token-01".to_string());
assert_eq!(token.token(), "refresh-token-01");
}
}

View File

@@ -9,6 +9,11 @@ pub struct AppConfig {
pub jwt_issuer: String,
pub jwt_secret: String,
pub jwt_access_token_ttl_seconds: u64,
pub refresh_cookie_name: String,
pub refresh_cookie_path: String,
pub refresh_cookie_secure: bool,
pub refresh_cookie_same_site: String,
pub refresh_session_ttl_days: u32,
}
impl Default for AppConfig {
@@ -20,6 +25,11 @@ impl Default for AppConfig {
jwt_issuer: "https://auth.genarrative.local".to_string(),
jwt_secret: "genarrative-dev-secret".to_string(),
jwt_access_token_ttl_seconds: 2 * 60 * 60,
refresh_cookie_name: "genarrative_refresh_session".to_string(),
refresh_cookie_path: "/api/auth".to_string(),
refresh_cookie_secure: false,
refresh_cookie_same_site: "Lax".to_string(),
refresh_session_ttl_days: 30,
}
}
}
@@ -65,6 +75,30 @@ impl AppConfig {
config.jwt_access_token_ttl_seconds = ttl_seconds;
}
if let Some(refresh_cookie_name) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_NAME"]) {
config.refresh_cookie_name = refresh_cookie_name;
}
if let Some(refresh_cookie_path) = read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_PATH"]) {
config.refresh_cookie_path = refresh_cookie_path;
}
if let Some(refresh_cookie_same_site) =
read_first_non_empty_env(&["AUTH_REFRESH_COOKIE_SAME_SITE"])
{
config.refresh_cookie_same_site = refresh_cookie_same_site;
}
if let Some(refresh_cookie_secure) = read_first_bool_env(&["AUTH_REFRESH_COOKIE_SECURE"]) {
config.refresh_cookie_secure = refresh_cookie_secure;
}
if let Some(refresh_session_ttl_days) =
read_first_positive_u32_env(&["AUTH_REFRESH_SESSION_TTL_DAYS"])
{
config.refresh_session_ttl_days = refresh_session_ttl_days;
}
config
}
@@ -97,6 +131,19 @@ fn read_first_duration_seconds_env(keys: &[&str]) -> Option<u64> {
})
}
fn read_first_bool_env(keys: &[&str]) -> Option<bool> {
keys.iter()
.find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value)))
}
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
keys.iter().find_map(|key| {
env::var(key)
.ok()
.and_then(|value| parse_positive_u32(&value))
})
}
fn parse_duration_seconds(raw: &str) -> Option<u64> {
let raw = raw.trim();
if raw.is_empty() {
@@ -121,3 +168,20 @@ fn parse_duration_seconds(raw: &str) -> Option<u64> {
number.checked_mul(multiplier)
}
fn parse_bool(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn parse_positive_u32(raw: &str) -> Option<u32> {
let value = raw.trim().parse::<u32>().ok()?;
if value == 0 {
return None;
}
Some(value)
}

View File

@@ -1,4 +1,8 @@
use platform_auth::{JwtConfig, JwtError};
use std::{error::Error, fmt};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
};
use crate::config::AppConfig;
@@ -9,23 +13,69 @@ pub struct AppState {
#[allow(dead_code)]
pub config: AppConfig,
auth_jwt_config: JwtConfig,
refresh_cookie_config: RefreshCookieConfig,
}
#[derive(Debug)]
pub enum AppStateInitError {
Jwt(JwtError),
RefreshCookie(RefreshCookieError),
}
impl AppState {
pub fn new(config: AppConfig) -> Result<Self, JwtError> {
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
let auth_jwt_config = JwtConfig::new(
config.jwt_issuer.clone(),
config.jwt_secret.clone(),
config.jwt_access_token_ttl_seconds,
)?;
let refresh_cookie_same_site =
RefreshCookieSameSite::parse(&config.refresh_cookie_same_site).ok_or(
RefreshCookieError::InvalidConfig("refresh cookie SameSite 取值非法"),
)?;
let refresh_cookie_config = RefreshCookieConfig::new(
config.refresh_cookie_name.clone(),
config.refresh_cookie_path.clone(),
config.refresh_cookie_secure,
refresh_cookie_same_site,
config.refresh_session_ttl_days,
)?;
Ok(Self {
config,
auth_jwt_config,
refresh_cookie_config,
})
}
pub fn auth_jwt_config(&self) -> &JwtConfig {
&self.auth_jwt_config
}
pub fn refresh_cookie_config(&self) -> &RefreshCookieConfig {
&self.refresh_cookie_config
}
}
impl fmt::Display for AppStateInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Jwt(error) => write!(f, "{error}"),
Self::RefreshCookie(error) => write!(f, "{error}"),
}
}
}
impl Error for AppStateInitError {}
impl From<JwtError> for AppStateInitError {
fn from(value: JwtError) -> Self {
Self::Jwt(value)
}
}
impl From<RefreshCookieError> for AppStateInitError {
fn from(value: RefreshCookieError) -> Self {
Self::RefreshCookie(value)
}
}

View File

@@ -8,3 +8,4 @@ license.workspace = true
jsonwebtoken = "9"
serde = { version = "1", features = ["derive"] }
time = { version = "0.3", features = ["std"] }
urlencoding = "2"

View File

@@ -20,11 +20,12 @@
2. 新增 `AccessTokenClaimsInput``AccessTokenClaims`,把文档中冻结的 `iss/sub/sid/provider/roles/ver/phone_verified/binding_status/display_name` 映射到 Rust 结构。
3. 新增 `sign_access_token(...)`,按 `HS256` 签发 access token。
4. 新增 `verify_access_token(...)`,统一校验 `iss/sub/exp/iat` 与 JWT 签名。
5. 增加单元测试,覆盖基本签发/校验、issuer 不匹配与空角色拒绝
5. 新增 `RefreshCookieConfig``RefreshCookieSameSite``read_refresh_session_token(...)`,统一 refresh cookie 读取口径
6. 增加单元测试,覆盖 JWT 回环、issuer 不匹配、空角色拒绝与 refresh cookie 读取。
当前阶段仍未进入:
1. refresh cookie 写与轮换。
1. refresh cookie 写与轮换。
2. 短信 provider 适配。
3. 微信 OAuth 适配。
4. `module-auth` 领域规则与数据库真相读取。
@@ -37,8 +38,11 @@
2. `AccessTokenClaims::from_input(...)`
3. `sign_access_token(...)`
4. `verify_access_token(...)`
5. `AuthProvider`
6. `BindingStatus`
5. `RefreshCookieConfig::new(...)`
6. `read_refresh_session_token(...)`
7. `AuthProvider`
8. `BindingStatus`
9. `RefreshCookieSameSite`
## 4. 配置口径
@@ -56,6 +60,8 @@
1. `JWT_EXPIRES_IN` 当前兼容 `2h``30m``900` 这类简单时长格式。
2. 当前阶段保持 `HS256`,优先保证与旧 Node 方案迁移平滑。
3. refresh cookie 当前采用 `AUTH_REFRESH_COOKIE_NAME``AUTH_REFRESH_COOKIE_PATH``AUTH_REFRESH_COOKIE_SAME_SITE``AUTH_REFRESH_COOKIE_SECURE``AUTH_REFRESH_SESSION_TTL_DAYS`
4. refresh cookie 默认值与 Node 基线保持一致:`genarrative_refresh_session``/api/auth``Lax``false``30` 天。
## 5. 边界约束
@@ -69,3 +75,4 @@
1. [../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](../../../docs/technical/OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md)
2. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
3. [../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)

View File

@@ -8,6 +8,9 @@ use time::{Duration, OffsetDateTime};
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
pub const DEFAULT_REFRESH_COOKIE_NAME: &str = "genarrative_refresh_session";
pub const DEFAULT_REFRESH_COOKIE_PATH: &str = "/api/auth";
pub const DEFAULT_REFRESH_SESSION_TTL_DAYS: u32 = 30;
// 鉴权 provider 直接冻结成文档中约定的枚举,避免后续在多个 crate 内重复发明字符串字面量。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -64,6 +67,24 @@ pub struct JwtConfig {
access_token_ttl_seconds: u64,
}
// refresh cookie 的 SameSite 固定约束成枚举,避免各层直接使用大小写不一致的字符串。
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshCookieSameSite {
Lax,
Strict,
None,
}
// refresh cookie 的平台配置统一收口到 platform-auth避免 api-server 直接散落 cookie 细节。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefreshCookieConfig {
cookie_name: String,
cookie_path: String,
cookie_secure: bool,
cookie_same_site: RefreshCookieSameSite,
refresh_session_ttl_days: u32,
}
#[derive(Debug, PartialEq, Eq)]
pub enum JwtError {
InvalidConfig(&'static str),
@@ -72,6 +93,11 @@ pub enum JwtError {
VerifyFailed(String),
}
#[derive(Debug, PartialEq, Eq)]
pub enum RefreshCookieError {
InvalidConfig(&'static str),
}
impl JwtConfig {
pub fn new(
issuer: String,
@@ -111,6 +137,84 @@ impl JwtConfig {
}
}
impl RefreshCookieSameSite {
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"lax" => Some(Self::Lax),
"strict" => Some(Self::Strict),
"none" => Some(Self::None),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Lax => "Lax",
Self::Strict => "Strict",
Self::None => "None",
}
}
}
impl RefreshCookieConfig {
pub fn new(
cookie_name: String,
cookie_path: String,
cookie_secure: bool,
cookie_same_site: RefreshCookieSameSite,
refresh_session_ttl_days: u32,
) -> Result<Self, RefreshCookieError> {
let cookie_name = cookie_name.trim().to_string();
let cookie_path = cookie_path.trim().to_string();
if cookie_name.is_empty() {
return Err(RefreshCookieError::InvalidConfig(
"refresh cookie 名称不能为空",
));
}
if cookie_path.is_empty() {
return Err(RefreshCookieError::InvalidConfig(
"refresh cookie path 不能为空",
));
}
if refresh_session_ttl_days == 0 {
return Err(RefreshCookieError::InvalidConfig(
"refresh session TTL 天数必须大于 0",
));
}
Ok(Self {
cookie_name,
cookie_path,
cookie_secure,
cookie_same_site,
refresh_session_ttl_days,
})
}
pub fn cookie_name(&self) -> &str {
&self.cookie_name
}
pub fn cookie_path(&self) -> &str {
&self.cookie_path
}
pub fn cookie_secure(&self) -> bool {
self.cookie_secure
}
pub fn cookie_same_site(&self) -> &RefreshCookieSameSite {
&self.cookie_same_site
}
pub fn refresh_session_ttl_days(&self) -> u32 {
self.refresh_session_ttl_days
}
}
impl AccessTokenClaims {
pub fn from_input(
input: AccessTokenClaimsInput,
@@ -233,6 +337,39 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
Ok(decoded.claims)
}
pub fn read_refresh_session_token(
cookie_header: &str,
config: &RefreshCookieConfig,
) -> Option<String> {
let cookie_header = cookie_header.trim();
if cookie_header.is_empty() {
return None;
}
for entry in cookie_header.split(';') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let (raw_name, raw_value) = entry.split_once('=')?;
if raw_name.trim() != config.cookie_name() {
continue;
}
let raw_value = raw_value.trim();
if raw_value.is_empty() {
return None;
}
return urlencoding::decode(raw_value)
.ok()
.map(|decoded| decoded.into_owned());
}
None
}
impl fmt::Display for JwtError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -244,6 +381,16 @@ impl fmt::Display for JwtError {
impl Error for JwtError {}
impl fmt::Display for RefreshCookieError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidConfig(message) => f.write_str(message),
}
}
}
impl Error for RefreshCookieError {}
fn normalize_required_field(
value: String,
error_message: &'static str,
@@ -322,6 +469,17 @@ mod tests {
}
}
fn build_refresh_cookie_config() -> RefreshCookieConfig {
RefreshCookieConfig::new(
DEFAULT_REFRESH_COOKIE_NAME.to_string(),
DEFAULT_REFRESH_COOKIE_PATH.to_string(),
false,
RefreshCookieSameSite::Lax,
DEFAULT_REFRESH_SESSION_TTL_DAYS,
)
.expect("refresh cookie config should be valid")
}
#[test]
fn round_trip_sign_and_verify_access_token() {
let config = build_jwt_config();
@@ -374,4 +532,32 @@ mod tests {
assert_eq!(error, JwtError::InvalidClaims("JWT roles 至少包含一个角色"));
}
#[test]
fn read_refresh_session_token_returns_matching_cookie() {
let token = read_refresh_session_token(
"theme=dark; genarrative_refresh_session=refresh-token-01; locale=zh-CN",
&build_refresh_cookie_config(),
);
assert_eq!(token.as_deref(), Some("refresh-token-01"));
}
#[test]
fn read_refresh_session_token_decodes_urlencoded_value() {
let token = read_refresh_session_token(
"genarrative_refresh_session=refresh%2Ftoken%3D01",
&build_refresh_cookie_config(),
);
assert_eq!(token.as_deref(), Some("refresh/token=01"));
}
#[test]
fn read_refresh_session_token_returns_none_when_missing() {
let token =
read_refresh_session_token("theme=dark; locale=zh-CN", &build_refresh_cookie_config());
assert!(token.is_none());
}
}