feat: add refresh cookie reader
This commit is contained in:
@@ -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 回归
|
||||
|
||||
@@ -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` 的会话吊销。
|
||||
@@ -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` 迁移规则。
|
||||
|
||||
@@ -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
|
||||
|
||||
交付:
|
||||
|
||||
7
server-rs/Cargo.lock
generated
7
server-rs/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ license.workspace = true
|
||||
jsonwebtoken = "9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
time = { version = "0.3", features = ["std"] }
|
||||
urlencoding = "2"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user