diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index df287fd7..34d40288 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -163,7 +163,8 @@ - [ ] 实现账号自动创建 / 幂等登录兼容策略 - [x] 实现 Bearer JWT 校验 交付物:[../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) -- [ ] 实现 refresh cookie 读取 +- [x] 实现 refresh cookie 读取 + 交付物:[../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth.rs](../server-rs/crates/api-server/src/auth.rs)、[../server-rs/crates/api-server/src/config.rs](../server-rs/crates/api-server/src/config.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs) - [ ] 实现 refresh token 轮换 - [ ] 实现会话吊销 - [ ] 实现全端登出 @@ -183,6 +184,11 @@ ### 微信登录 +当前执行策略: + +1. 微信登录链路自 `2026-04-21` 起暂缓执行,不进入当前连续落地顺序。 +2. 相关设计文档继续保留,后续如恢复执行再单独解锁。 + - [ ] 接入微信 OAuth adapter - [ ] 实现 `wechat/start` - [ ] 实现 `wechat/callback` @@ -227,4 +233,5 @@ - [ ] refresh cookie 主链可用 - [ ] 手机验证码主链可用 - [ ] 微信登录主链可用 + 说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。 - [ ] 所有旧鉴权接口可通过 contract 回归 diff --git a/docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md b/docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md new file mode 100644 index 00000000..e251a63b --- /dev/null +++ b/docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md @@ -0,0 +1,173 @@ +# platform-auth refresh cookie 适配设计 + +日期:`2026-04-21` + +## 1. 文档目的 + +这份文档用于指导 `M2` 中“实现 refresh cookie 读取”这条任务落地,目标是先把 refresh cookie 的读取边界、配置口径与 `api-server` 的最小接线固定下来。 + +本阶段只解决: + +1. `platform-auth` 如何统一读取 refresh cookie。 +2. `api-server` 如何把读取结果挂到请求上下文。 + +本阶段不解决: + +1. refresh token 轮换。 +2. refresh session 吊销。 +3. refresh cookie 写回浏览器。 + +## 2. 设计输入 + +本任务直接受以下文档约束: + +1. [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md) +2. [SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md](./SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md) + +关键冻结点: + +1. 浏览器 cookie 只存原始 refresh token。 +2. 服务端真相表只存 `sha256(refresh_token)`。 +3. cookie 名默认是 `genarrative_refresh_session`。 +4. cookie `Path` 默认是 `/api/auth`。 +5. 默认 `SameSite=Lax`。 + +## 3. crate 边界 + +### 3.1 `platform-auth` + +负责: + +1. 统一 refresh cookie 配置结构。 +2. 统一 cookie 名、Path、SameSite、Secure、TTL 的平台配置。 +3. 从 `Cookie` 请求头解析当前 refresh token。 + +不负责: + +1. 直接操作 `Set-Cookie` 响应头。 +2. 决定请求是否应该被视为已登录。 +3. 访问 `refresh_session` 真相表。 + +### 3.2 `api-server` + +负责: + +1. 从环境变量读取 refresh cookie 配置。 +2. 在启动时构造唯一一份 `RefreshCookieConfig`。 +3. 在请求链中读取 refresh cookie 并挂到 request extensions。 +4. 为阶段验收提供最小内部调试入口。 + +不负责: + +1. 重写一套独立 cookie 解析逻辑。 +2. 在这一步直接实现 `/api/auth/refresh`。 + +## 4. 配置口径 + +当前阶段 `api-server` 读取以下环境变量: + +| 配置项 | 环境变量 | 默认值 | 说明 | +| --- | --- | --- | --- | +| cookie 名 | `AUTH_REFRESH_COOKIE_NAME` | `genarrative_refresh_session` | 读取 refresh cookie 时匹配的键名。 | +| cookie path | `AUTH_REFRESH_COOKIE_PATH` | `/api/auth` | 当前阶段只用于冻结配置口径。 | +| cookie secure | `AUTH_REFRESH_COOKIE_SECURE` | `false` | 当前阶段只读配置,不在读取路径参与判断。 | +| cookie same-site | `AUTH_REFRESH_COOKIE_SAME_SITE` | `Lax` | 固定枚举为 `Lax`、`Strict`、`None`。 | +| session TTL 天数 | `AUTH_REFRESH_SESSION_TTL_DAYS` | `30` | 当前阶段只读配置,不参与读取判断。 | + +说明: + +1. 这组变量直接对齐当前 Node 口径。 +2. 本阶段读取链路只真正依赖 `cookie_name`,其余配置主要用于冻结统一初始化边界。 + +## 5. Rust 结构设计 + +### 5.1 `RefreshCookieSameSite` + +用途: + +1. 避免不同 crate 手写 `Lax` / `Strict` / `None` 字符串。 +2. 在启动阶段尽早拒绝非法配置。 + +### 5.2 `RefreshCookieConfig` + +字段: + +1. `cookie_name` +2. `cookie_path` +3. `cookie_secure` +4. `cookie_same_site` +5. `refresh_session_ttl_days` + +约束: + +1. `cookie_name` 不能为空。 +2. `cookie_path` 不能为空。 +3. `refresh_session_ttl_days` 必须大于 `0`。 + +## 6. 读取规则 + +`read_refresh_session_token(cookie_header, config)` 固定按以下规则工作: + +1. 若 `Cookie` 头为空,返回 `None`。 +2. 按 `;` 分割各 cookie 项。 +3. 只匹配与 `config.cookie_name` 完全相等的键名。 +4. 若匹配项值为空,返回 `None`。 +5. 对匹配值执行 URL decode。 +6. decode 成功则返回原始 refresh token,失败则返回 `None`。 + +说明: + +1. 当前阶段读取逻辑只做“解析”,不做权限判断。 +2. 请求是否能继续 refresh,要在后续应用层结合 `refresh_session` 真相表判断。 + +## 7. api-server 最小接线 + +本阶段 `api-server` 只做三件事: + +1. `AppConfig` 增加 refresh cookie 配置。 +2. `AppState` 启动时构造 `RefreshCookieConfig`。 +3. `attach_refresh_session_token` 中间件从请求头读取 cookie,并把结果以 `RefreshSessionToken` 写入 request extensions。 + +阶段验收入口: + +1. `/_internal/auth/refresh-cookie` + +该入口仅返回: + +1. `cookieName` +2. `present` +3. `tokenLength` + +说明: + +1. 当前不回显原始 refresh token,避免把敏感值直接暴露到响应体中。 +2. `tokenLength` 只用于阶段测试与调试,不是最终对外 contract。 + +## 8. 测试策略 + +当前阶段至少覆盖: + +1. 纯函数能正确提取目标 cookie。 +2. URL 编码的 cookie 值能正确解码。 +3. 缺少目标 cookie 时返回空。 +4. `api-server` 在无 cookie 时能返回 `present = false`。 +5. `api-server` 在有 cookie 时能返回 `present = true`。 + +## 9. 完成定义 + +满足以下条件时,本任务视为完成: + +1. `platform-auth` 中存在真实可编译的 refresh cookie 配置与读取逻辑。 +2. `api-server` 已接入统一 refresh cookie 配置。 +3. 请求链已能读取 cookie 并挂到 extensions。 +4. 编译与测试通过。 +5. 任务清单已同步更新。 + +## 10. 后续衔接 + +这条任务完成后,下一步继续衔接: + +1. refresh token 哈希与 `refresh_session` 表查询。 +2. refresh token 轮换。 +3. `/api/auth/refresh` 正式接口。 +4. `/api/auth/logout`、`/api/auth/logout-all` 的会话吊销。 diff --git a/docs/technical/README.md b/docs/technical/README.md index d0a48fde..c0ca22b8 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,6 +4,7 @@ ## 文档列表 +- [PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` refresh cookie 适配设计,冻结 cookie 配置结构、读取规则与 `api-server` 最小读取链路。 - [PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](./PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md):`platform-auth` 首版 JWT 适配设计,冻结 `JwtConfig`、claims 结构、`HS256` 签发/校验、`api-server` Bearer 中间件与内部验收路由边界。 - [OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md](./OIDC_JWT_CLAIMS_DESIGN_2026-04-21.md):面向 Axum、`platform-auth` 与 `SpacetimeDB` 身份透传的 OIDC 风格 JWT claims 设计,冻结 `iss/sub/sid/provider/roles` 等关键字段。 - [RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md](./RUST_SHARED_LOGGING_CRATE_DESIGN_2026-04-21.md):Rust 工作区统一日志模块 `shared-logging` 的职责边界、API、输出风格与 `api-server` 迁移规则。 diff --git a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md index 69abac45..f6bdb979 100644 --- a/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md +++ b/docs/technical/SPACETIMEDB_AXUM_OSS_BACKEND_REWRITE_DESIGN_2026-04-20.md @@ -733,6 +733,11 @@ workflow-cache/{workflow_type}/{workflow_id}.json 5. Axum -> SpacetimeDB 基础 client 6. OIDC-compatible JWT 签发 +阶段执行补充: + +1. 微信登录链路在当前阶段暂缓,不进入连续执行顺序。 +2. 当前优先顺序固定为:JWT / refresh cookie / 密码登录 / 手机验证码登录。 + ## Phase 2:迁移 runtime snapshot / settings / profile 交付: diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 9785f547..6b2151de 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -524,6 +524,7 @@ dependencies = [ "jsonwebtoken", "serde", "time", + "urlencoding", ] [[package]] @@ -963,6 +964,12 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "uuid" version = "1.23.1" diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 062a894c..d5a51583 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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)) + ); + } } diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index c987d9b5..272bb928 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -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, mut request: Request, @@ -69,6 +87,44 @@ pub async fn inspect_auth_claims( ) } +pub async fn attach_refresh_session_token( + State(state): State, + 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, + Extension(request_context): Extension, + request: Request, +) -> Json { + let maybe_token = request.extensions().get::(); + + 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 { let authorization = headers .get(AUTHORIZATION) @@ -88,7 +144,7 @@ fn extract_bearer_token(headers: &HeaderMap) -> Result { #[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"); + } } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 303349c6..7176e05d 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -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 { }) } +fn read_first_bool_env(keys: &[&str]) -> Option { + keys.iter() + .find_map(|key| env::var(key).ok().and_then(|value| parse_bool(&value))) +} + +fn read_first_positive_u32_env(keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + env::var(key) + .ok() + .and_then(|value| parse_positive_u32(&value)) + }) +} + fn parse_duration_seconds(raw: &str) -> Option { let raw = raw.trim(); if raw.is_empty() { @@ -121,3 +168,20 @@ fn parse_duration_seconds(raw: &str) -> Option { number.checked_mul(multiplier) } + +fn parse_bool(raw: &str) -> Option { + 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 { + let value = raw.trim().parse::().ok()?; + if value == 0 { + return None; + } + + Some(value) +} diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 5b51bdd3..4d4672e2 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -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 { + pub fn new(config: AppConfig) -> Result { 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 for AppStateInitError { + fn from(value: JwtError) -> Self { + Self::Jwt(value) + } +} + +impl From for AppStateInitError { + fn from(value: RefreshCookieError) -> Self { + Self::RefreshCookie(value) + } } diff --git a/server-rs/crates/platform-auth/Cargo.toml b/server-rs/crates/platform-auth/Cargo.toml index 30216789..956a8ea5 100644 --- a/server-rs/crates/platform-auth/Cargo.toml +++ b/server-rs/crates/platform-auth/Cargo.toml @@ -8,3 +8,4 @@ license.workspace = true jsonwebtoken = "9" serde = { version = "1", features = ["derive"] } time = { version = "0.3", features = ["std"] } +urlencoding = "2" diff --git a/server-rs/crates/platform-auth/README.md b/server-rs/crates/platform-auth/README.md index c7fdd973..eff1d349 100644 --- a/server-rs/crates/platform-auth/README.md +++ b/server-rs/crates/platform-auth/README.md @@ -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) diff --git a/server-rs/crates/platform-auth/src/lib.rs b/server-rs/crates/platform-auth/src/lib.rs index da2198de..387a6c5f 100644 --- a/server-rs/crates/platform-auth/src/lib.rs +++ b/server-rs/crates/platform-auth/src/lib.rs @@ -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 { + 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 { + 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 Option { + 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()); + } }