feat: add refresh token rotation flow

This commit is contained in:
2026-04-21 15:27:04 +08:00
parent 70dbefda2b
commit 584a77e572
16 changed files with 1048 additions and 85 deletions

View File

@@ -167,7 +167,8 @@
交付物:[../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)
- [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 轮换
- [x] 实现 refresh token 轮换
交付物:[../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)、[../server-rs/crates/module-auth/src/lib.rs](../server-rs/crates/module-auth/src/lib.rs)、[../server-rs/crates/platform-auth/src/lib.rs](../server-rs/crates/platform-auth/src/lib.rs)、[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/password_entry.rs](../server-rs/crates/api-server/src/password_entry.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [ ] 实现会话吊销
- [ ] 实现全端登出
- [x] 实现 `me` 查询
@@ -219,7 +220,8 @@
交付物:[../server-rs/crates/api-server/src/auth_me.rs](../server-rs/crates/api-server/src/auth_me.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [ ] 兼容 `/api/auth/logout`
- [ ] 兼容 `/api/auth/logout-all`
- [ ] 兼容 `/api/auth/refresh`
- [x] 兼容 `/api/auth/refresh`
交付物:[../server-rs/crates/api-server/src/auth_session.rs](../server-rs/crates/api-server/src/auth_session.rs)、[../server-rs/crates/api-server/src/app.rs](../server-rs/crates/api-server/src/app.rs)
- [ ] 兼容 `/api/auth/sessions`
- [ ] 兼容 `/api/auth/sessions/:sessionId/revoke`
- [ ] 兼容 `/api/auth/audit-logs`
@@ -236,7 +238,8 @@
- [x] 密码登录主链可用
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖自动建号、重复登录复用、错密码 `401`、非法用户名 `400` 与 refresh cookie 写回。
- [ ] refresh cookie 主链可用
- [x] refresh cookie 主链可用
证据:`cargo test -p module-auth --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml` 已通过,覆盖 refresh 成功轮换、旧 token 失效、缺少 cookie `401` 与失败时清理 cookie。
- [ ] 手机验证码主链可用
- [ ] 微信登录主链可用
说明:当前按“暂缓执行”处理,不作为当前连续阶段的阻塞项。

View File

@@ -0,0 +1,205 @@
# `/api/auth/refresh` 轮换落地设计
日期:`2026-04-21`
## 1. 文档目的
这份文档用于指导 `M2``实现 refresh token 轮换` 任务的首版落地,冻结:
1. `POST /api/auth/refresh` 的请求与响应 contract。
2. refresh cookie、服务端 refresh session 与 access token 三者的职责边界。
3. “会话 ID 稳定、refresh token 可轮换”的固定语义。
4. Rust 首版在未切入 SpacetimeDB 前的临时进程内实现方式。
## 2. 当前基线
当前 Node `/api/auth/refresh` 已具备以下稳定语义:
1. 从 HttpOnly cookie 中读取原始 refresh token。
2. 服务端只按 `refresh_token_hash` 查找当前活跃会话。
3. refresh 成功后,不新建第二条会话,而是在原会话上轮换 refresh token。
4. 轮换时会更新 `expires_at``last_seen_at`
5. 成功后返回新的 access token并写回新的 refresh cookie。
6. 失败时会主动清空 refresh cookie要求前端重新登录。
Rust 首版必须保留以上语义。
## 3. 当前阶段范围
本阶段只落以下内容:
1. `module-auth` 增加进程内 refresh session 真相与轮换服务。
2. `api-server` 暴露 `POST /api/auth/refresh`
3. 登录成功时创建 refresh session。
4. refresh 成功时在原 session 上轮换 refresh token。
5. access token 的 `sid` 固定改为稳定 `session_id`,不再直接复用 refresh token。
本阶段不包含:
1. `/api/auth/logout`
2. `/api/auth/logout-all`
3. `/api/auth/sessions`
4. `/api/auth/sessions/:sessionId/revoke`
5. SpacetimeDB reducer 真正写表
## 4. contract
### 4.1 请求
1. 方法:`POST`
2. 路径:`/api/auth/refresh`
3. 请求体:空
4. 鉴权来源refresh cookie
### 4.2 成功响应
```json
{
"token": "<access-token>"
}
```
同时响应头必须写回新的 refresh cookie。
### 4.3 失败响应
当 refresh token 缺失、会话不存在、会话已过期或用户不存在时:
1. 返回 `401 UNAUTHORIZED`
2. 同时清理 refresh cookie
## 5. 固定语义
### 5.1 session_id 与 refresh token 必须拆开
从本任务开始固定以下规则:
1. `session_id` 是稳定会话主键。
2. refresh token 是可轮换的会话凭证。
3. access token 的 `sid` 必须写入 `session_id`
4. refresh 轮换只更新 refresh token不更改 `session_id`
禁止继续把 refresh token 直接塞进 JWT `sid`
### 5.2 refresh 是“原会话轮换”
refresh 成功后:
1. 保留原 `session_id`
2. 生成新的原始 refresh token
3. 用新的 `refresh_token_hash` 覆盖旧值
4. 更新 `expires_at`
5. 更新 `last_seen_at`
不允许新建第二条 session。
## 6. crate 边界
### 6.1 `module-auth`
负责:
1. 管理 refresh session 进程内真相。
2. 提供创建 refresh session 与轮换 refresh session 的用例。
3. 提供按 `user_id` 查询用户快照的能力,供 refresh 成功后重新签发 access token。
不负责:
1. 生成原始 refresh token。
2. 读写 cookie。
3. 签发 JWT。
### 6.2 `platform-auth`
负责:
1. 生成原始 refresh token。
2. 对 refresh token 做哈希。
3. 构造 refresh cookie 的 `Set-Cookie` 头。
4. 从 cookie header 中读取 refresh token。
### 6.3 `api-server`
负责:
1. 从请求 cookie 中提取 refresh token。
2. 调用 `module-auth` 执行 refresh session 轮换。
3. 根据用户快照与稳定 `session_id` 重新签发 access token。
4. refresh 失败时清理 cookie。
## 7. 进程内存储模型
当前阶段 `module-auth` 继续使用进程内内存仓储承接 refresh session字段至少包括
1. `session_id`
2. `user_id`
3. `refresh_token_hash`
4. `issued_by_provider`
5. `expires_at`
6. `created_at`
7. `updated_at`
8. `last_seen_at`
9. `revoked_at`
说明:
1. 这只是 SpacetimeDB 正式落地前的阶段性实现。
2. 字段命名与语义继续对齐 [SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md](./SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md)。
## 8. 流程
### 8.1 登录创建 session
密码登录成功后:
1. `api-server` 生成原始 refresh token。
2. `api-server` 计算 `refresh_token_hash`
3. `module-auth` 创建一条新 session并返回稳定 `session_id`
4. `api-server` 用该 `session_id` 写入 access token 的 `sid`
5. `api-server` 把原始 refresh token 写回 cookie。
### 8.2 refresh 轮换 session
当请求 `POST /api/auth/refresh` 时:
1. 从 cookie 中读取原始 refresh token。
2. 计算 `refresh_token_hash`
3. `module-auth` 查找当前活跃 session。
4. 校验 `expires_at > now``revoked_at == null`
5. 读取该 session 对应用户。
6. 生成新的原始 refresh token。
7. 用新 hash 更新同一条 session。
8. 返回新的 access token 与新的 refresh cookie。
## 9. 错误语义
以下情况统一返回 `401`
1. 缺少 refresh cookie
2. refresh token 命中不到 session
3. refresh session 已过期
4. refresh session 已吊销
5. session 对应用户不存在
错误文案统一保持中文,并沿用“当前登录态已失效,请重新登录”这类恢复导向语义。
## 10. 测试策略
至少覆盖:
1. 登录成功后可用 cookie 调用 `/api/auth/refresh`
2. refresh 成功会写回新的 cookie
3. refresh 成功返回新的 access token
4. refresh 后旧 refresh token 立即失效
5. 缺少 cookie 时返回 `401`
6. 无效 refresh token 时返回 `401` 且清理 cookie
## 11. 完成定义
满足以下条件时,本任务视为完成:
1. Rust 侧已提供 `POST /api/auth/refresh`
2. access token `sid` 已改为稳定 `session_id`
3. refresh token 轮换成功时不创建新会话。
4. refresh 失败时会清理 cookie。
5. 文档、任务清单与测试已同步更新。

14
server-rs/Cargo.lock generated
View File

@@ -545,7 +545,9 @@ name = "module-auth"
version = "0.1.0"
dependencies = [
"platform-auth",
"time",
"tokio",
"uuid",
]
[[package]]
@@ -638,6 +640,7 @@ dependencies = [
"jsonwebtoken",
"rand_core",
"serde",
"sha2",
"time",
"tokio",
"urlencoding",
@@ -831,6 +834,17 @@ dependencies = [
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"

View File

@@ -31,6 +31,7 @@
9. 接入 `POST /api/auth/entry` 首版密码登录链路
10. 接入 `POST /api/assets/direct-upload-tickets` 直传票据接口
11. 接入 `GET /api/auth/me` 当前用户查询链路
12. 接入 `POST /api/auth/refresh` refresh token 轮换链路
后续与本 crate 直接相关的任务包括:
@@ -42,6 +43,7 @@
6. [x] 接入 `/api/auth/entry`
7. [x] 接入 `/api/assets/direct-upload-tickets`
8. [x] 接入 `/api/auth/me`
9. [x] 接入 `/api/auth/refresh`
当前 tracing 约定:
@@ -102,3 +104,4 @@
4. 不把领域规则直接堆在 handler 中。
5. 当前密码登录由 `module-auth` 负责用例编排,`api-server` 只负责请求解析、JWT 签发与 refresh cookie 写回。
6. 当前 `/api/auth/me` 复用现有 Bearer JWT 中间件与 `module-auth` 用户快照查询,不直接绕过模块边界读取内部状态。
7. 当前 `/api/auth/refresh` 复用 `module-auth` 的 refresh session 轮换能力,`api-server` 负责 refresh cookie 读取、失败清理与 access token 重签。

View File

@@ -19,6 +19,7 @@ use crate::{
error_middleware::normalize_error_response,
health::health_check,
password_entry::password_entry,
refresh_session::refresh_session,
request_context::{attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
state::AppState,
@@ -54,6 +55,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/auth/refresh",
post(refresh_session).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_refresh_session_token,
)),
)
.route(
"/api/assets/direct-upload-tickets",
post(create_direct_upload_ticket),
@@ -616,4 +624,112 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn refresh_session_rotates_cookie_and_returns_new_access_token() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/entry")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"username": "guest_refresh",
"password": "secret123"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
let first_cookie = login_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("refresh cookie should exist")
.to_string();
let refresh_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", first_cookie.clone())
.body(Body::empty())
.expect("refresh request should build"),
)
.await
.expect("refresh request should succeed");
assert_eq!(refresh_response.status(), StatusCode::OK);
let second_cookie = refresh_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.expect("rotated refresh cookie should exist")
.to_string();
assert_ne!(first_cookie, second_cookie);
let refresh_body = refresh_response
.into_body()
.collect()
.await
.expect("refresh body should collect")
.to_bytes();
let refresh_payload: Value =
serde_json::from_slice(&refresh_body).expect("refresh payload should be json");
assert!(refresh_payload["token"].as_str().is_some());
let stale_refresh_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.header("cookie", first_cookie)
.body(Body::empty())
.expect("stale refresh request should build"),
)
.await
.expect("stale refresh request should succeed");
assert_eq!(stale_refresh_response.status(), StatusCode::UNAUTHORIZED);
assert!(
stale_refresh_response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
#[tokio::test]
async fn refresh_session_rejects_missing_cookie_and_clears_cookie() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/refresh")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(
response
.headers()
.get("set-cookie")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value.contains("Max-Age=0"))
);
}
}

View File

@@ -0,0 +1,132 @@
use axum::http::{
HeaderMap, HeaderValue, StatusCode,
header::SET_COOKIE,
};
use module_auth::{
AuthLoginMethod, AuthUser, CreateRefreshSessionInput, RefreshSessionError,
};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
build_refresh_session_clear_cookie, build_refresh_session_set_cookie,
create_refresh_session_token, hash_refresh_session_token, sign_access_token,
};
use time::OffsetDateTime;
use crate::{http_error::AppError, state::AppState};
#[derive(Debug, Clone)]
pub struct SignedAuthSession {
pub access_token: String,
pub refresh_token: String,
}
pub fn create_password_auth_session(
state: &AppState,
user: &AuthUser,
) -> Result<SignedAuthSession, AppError> {
let refresh_token = create_refresh_session_token();
let refresh_token_hash = hash_refresh_session_token(&refresh_token);
let session = state
.refresh_session_service()
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash,
issued_by_provider: AuthLoginMethod::Password,
},
OffsetDateTime::now_utc(),
)
.map_err(map_refresh_session_error)?;
let access_token = sign_access_token_for_user(state, user, &session.session.session_id)?;
Ok(SignedAuthSession {
access_token,
refresh_token,
})
}
pub fn sign_access_token_for_user(
state: &AppState,
user: &AuthUser,
session_id: &str,
) -> Result<String, AppError> {
let access_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: user.id.clone(),
session_id: session_id.to_string(),
provider: map_auth_provider(&user.login_method),
roles: vec!["user".to_string()],
token_version: user.token_version,
phone_verified: user.phone_number_masked.is_some(),
binding_status: map_binding_status(&user.binding_status),
display_name: Some(user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})
}
pub fn build_refresh_session_cookie_header(
state: &AppState,
refresh_token: &str,
) -> Result<HeaderValue, AppError> {
let refresh_cookie =
build_refresh_session_set_cookie(refresh_token, state.refresh_cookie_config());
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})
}
pub fn build_clear_refresh_session_cookie_header(
state: &AppState,
) -> Result<HeaderValue, AppError> {
let refresh_cookie = build_refresh_session_clear_cookie(state.refresh_cookie_config());
HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})
}
pub fn attach_set_cookie_header(
headers: &mut HeaderMap,
set_cookie: HeaderValue,
) {
headers.insert(SET_COOKIE, set_cookie);
}
pub fn map_refresh_session_error(error: RefreshSessionError) -> AppError {
match error {
RefreshSessionError::MissingToken
| RefreshSessionError::SessionNotFound
| RefreshSessionError::SessionExpired
| RefreshSessionError::UserNotFound => {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error.to_string())
}
RefreshSessionError::Store(message) => {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(message)
}
}
}
fn map_auth_provider(login_method: &AuthLoginMethod) -> AuthProvider {
match login_method {
AuthLoginMethod::Password => AuthProvider::Password,
AuthLoginMethod::Phone => AuthProvider::Phone,
AuthLoginMethod::Wechat => AuthProvider::Wechat,
}
}
fn map_binding_status(binding_status: &module_auth::AuthBindingStatus) -> BindingStatus {
match binding_status {
module_auth::AuthBindingStatus::Active => BindingStatus::Active,
module_auth::AuthBindingStatus::PendingBindPhone => BindingStatus::PendingBindPhone,
}
}

View File

@@ -1,4 +1,5 @@
use axum::{
http::{HeaderMap, HeaderValue},
http::StatusCode,
response::{IntoResponse, Response},
};
@@ -13,6 +14,7 @@ pub struct AppError {
code: &'static str,
message: String,
details: Option<Value>,
headers: HeaderMap,
}
#[derive(Clone, Debug, Serialize)]
@@ -32,6 +34,7 @@ impl AppError {
code,
message: message.to_string(),
details: None,
headers: HeaderMap::new(),
}
}
@@ -49,11 +52,17 @@ impl AppError {
self
}
pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self {
self.headers.insert(name, value);
self
}
pub fn into_response_with_context(self, request_context: Option<&RequestContext>) -> Response {
let status_code = self.status_code;
let payload = self.to_payload();
(status_code, json_error_body(request_context, &payload)).into_response()
let mut response = (status_code, json_error_body(request_context, &payload)).into_response();
response.headers_mut().extend(self.headers);
response
}
fn to_payload(&self) -> ApiErrorPayload {

View File

@@ -2,12 +2,14 @@ mod api_response;
mod app;
mod assets;
mod auth;
mod auth_session;
mod auth_me;
mod config;
mod error_middleware;
mod health;
mod http_error;
mod password_entry;
mod refresh_session;
mod request_context;
mod response_headers;
mod state;

View File

@@ -1,20 +1,20 @@
use axum::{
Json,
extract::{Extension, State},
http::{HeaderMap, HeaderValue, StatusCode, header::SET_COOKIE},
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use module_auth::{PasswordEntryError, PasswordEntryInput};
use platform_auth::{
AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus,
build_refresh_session_set_cookie, create_refresh_session_token, sign_access_token,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body, http_error::AppError, request_context::RequestContext,
api_response::json_success_body,
auth_session::{
attach_set_cookie_header, build_refresh_session_cookie_header, create_password_auth_session,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
@@ -57,45 +57,20 @@ pub async fn password_entry(
})
.await
.map_err(map_password_entry_error)?;
let refresh_session_token = create_refresh_session_token();
let access_claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: result.user.id.clone(),
session_id: refresh_session_token.clone(),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: result.user.token_version,
phone_verified: false,
binding_status: BindingStatus::Active,
display_name: Some(result.user.display_name.clone()),
},
state.auth_jwt_config(),
OffsetDateTime::now_utc(),
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
let access_token =
sign_access_token(&access_claims, state.auth_jwt_config()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
let refresh_cookie =
build_refresh_session_set_cookie(&refresh_session_token, state.refresh_cookie_config());
let signed_session = create_password_auth_session(&state, &result.user)?;
let mut headers = HeaderMap::new();
let set_cookie = HeaderValue::from_str(&refresh_cookie).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
.with_message(format!("refresh cookie 头构造失败:{error}"))
})?;
headers.insert(SET_COOKIE, set_cookie);
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
PasswordEntryResponse {
token: access_token,
token: signed_session.access_token,
user: PasswordEntryUserPayload {
id: result.user.id,
username: result.user.username,

View File

@@ -0,0 +1,84 @@
use axum::{
extract::{Extension, State},
http::HeaderMap,
response::IntoResponse,
};
use module_auth::{RefreshSessionError, RotateRefreshSessionInput};
use platform_auth::hash_refresh_session_token;
use serde::Serialize;
use time::OffsetDateTime;
use crate::{
api_response::json_success_body,
auth::RefreshSessionToken,
auth_session::{
attach_set_cookie_header, build_clear_refresh_session_cookie_header,
build_refresh_session_cookie_header, map_refresh_session_error, sign_access_token_for_user,
},
http_error::AppError,
request_context::RequestContext,
state::AppState,
};
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RefreshSessionResponse {
pub token: String,
}
pub async fn refresh_session(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
maybe_refresh_token: Option<Extension<RefreshSessionToken>>,
) -> Result<impl IntoResponse, AppError> {
let raw_refresh_token = maybe_refresh_token
.map(|token| token.0.token().to_string())
.unwrap_or_default();
if raw_refresh_token.trim().is_empty() {
return Err(map_refresh_error_with_clear_cookie(
&state,
RefreshSessionError::MissingToken,
));
}
let refresh_token_hash = hash_refresh_session_token(&raw_refresh_token);
let next_refresh_token = platform_auth::create_refresh_session_token();
let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token);
let rotated = state
.refresh_session_service()
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash,
next_refresh_token_hash,
},
OffsetDateTime::now_utc(),
)
.map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?;
let access_token =
sign_access_token_for_user(&state, &rotated.user, &rotated.session.session_id)?;
let mut headers = HeaderMap::new();
attach_set_cookie_header(
&mut headers,
build_refresh_session_cookie_header(&state, &next_refresh_token)?,
);
Ok((
headers,
json_success_body(
Some(&request_context),
RefreshSessionResponse {
token: access_token,
},
),
))
}
fn map_refresh_error_with_clear_cookie(state: &AppState, error: RefreshSessionError) -> AppError {
let response_error = map_refresh_session_error(error);
if let Ok(set_cookie) = build_clear_refresh_session_cookie_header(state) {
return response_error.with_header("set-cookie", set_cookie);
}
response_error
}

View File

@@ -1,6 +1,6 @@
use std::{error::Error, fmt};
use module_auth::{InMemoryPasswordUserStore, PasswordEntryService};
use module_auth::{InMemoryAuthStore, PasswordEntryService, RefreshSessionService};
use platform_auth::{
JwtConfig, JwtError, RefreshCookieConfig, RefreshCookieError, RefreshCookieSameSite,
};
@@ -18,6 +18,7 @@ pub struct AppState {
refresh_cookie_config: RefreshCookieConfig,
oss_client: Option<OssClient>,
password_entry_service: PasswordEntryService,
refresh_session_service: RefreshSessionService,
}
#[derive(Debug)]
@@ -46,8 +47,10 @@ impl AppState {
config.refresh_session_ttl_days,
)?;
let oss_client = build_oss_client(&config)?;
let password_entry_service =
PasswordEntryService::new(InMemoryPasswordUserStore::default());
let auth_store = InMemoryAuthStore::default();
let password_entry_service = PasswordEntryService::new(auth_store.clone());
let refresh_session_service =
RefreshSessionService::new(auth_store, config.refresh_session_ttl_days);
Ok(Self {
config,
@@ -55,6 +58,7 @@ impl AppState {
refresh_cookie_config,
oss_client,
password_entry_service,
refresh_session_service,
})
}
@@ -73,6 +77,10 @@ impl AppState {
pub fn password_entry_service(&self) -> &PasswordEntryService {
&self.password_entry_service
}
pub fn refresh_session_service(&self) -> &RefreshSessionService {
&self.refresh_session_service
}
}
impl fmt::Display for AppStateInitError {
@@ -109,8 +117,7 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
let has_any_oss_field = config.oss_bucket.is_some()
|| config.oss_endpoint.is_some()
|| config.oss_access_key_id.is_some()
|| config.oss_access_key_secret.is_some()
|| config.oss_public_base_url.is_some();
|| config.oss_access_key_secret.is_some();
if !has_any_oss_field {
return Ok(None);
@@ -121,7 +128,7 @@ fn build_oss_client(config: &AppConfig) -> Result<Option<OssClient>, AppStateIni
config.oss_endpoint.clone().unwrap_or_default(),
config.oss_access_key_id.clone().unwrap_or_default(),
config.oss_access_key_secret.clone().unwrap_or_default(),
config.oss_public_base_url.clone(),
config.oss_read_expire_seconds,
config.oss_post_expire_seconds,
config.oss_post_max_size_bytes,
config.oss_success_action_status,

View File

@@ -6,6 +6,8 @@ license.workspace = true
[dependencies]
platform-auth = { path = "../platform-auth" }
time = { version = "0.3", features = ["formatting", "parsing"] }
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt"] }

View File

@@ -23,8 +23,8 @@
当前连续实现优先顺序固定为:
1. 密码登录
2. `me` 查询
3. refresh token 轮换
2. refresh token 轮换
3. `me` 查询
4. 会话吊销
5. 手机验证码登录
@@ -41,6 +41,7 @@
9. [../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_JWT_ADAPTER_DESIGN_2026-04-21.md)
10. [../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md](../../../docs/technical/PLATFORM_AUTH_REFRESH_COOKIE_ADAPTER_DESIGN_2026-04-21.md)
11. [../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md](../../../docs/technical/PASSWORD_ENTRY_FLOW_DESIGN_2026-04-21.md)
12. [../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md](../../../docs/technical/AUTH_REFRESH_ROTATION_DESIGN_2026-04-21.md)
## 4. 边界约束
@@ -50,3 +51,4 @@
4. 当前阶段允许先使用进程内适配器把用例跑通,但后续切到 `SpacetimeDB` 时应保持用例接口稳定。
5. 当前 `PasswordEntryService` 已承接用户名校验、密码哈希校验、自动建号与重复登录复用逻辑。
6. 当前 `PasswordEntryService` 已提供按 `user_id` 查询当前用户快照的能力,供 `/api/auth/me` 复用。
7. 当前 `module-auth` 已承接进程内 refresh session 创建与轮换能力,供 `/api/auth/refresh` 复用。

View File

@@ -6,6 +6,8 @@ use std::{
};
use platform_auth::{hash_password, verify_password};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
const USERNAME_MIN_LENGTH: usize = 3;
const USERNAME_MAX_LENGTH: usize = 24;
@@ -37,6 +39,11 @@ pub struct AuthUser {
pub token_version: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub user: AuthUser,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PasswordEntryInput {
pub username: String,
@@ -50,7 +57,39 @@ pub struct PasswordEntryResult {
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AuthMeResult {
pub struct CreateRefreshSessionInput {
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RefreshSessionRecord {
pub session_id: String,
pub user_id: String,
pub refresh_token_hash: String,
pub issued_by_provider: AuthLoginMethod,
pub expires_at: String,
pub revoked_at: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_seen_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CreateRefreshSessionResult {
pub session: RefreshSessionRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionInput {
pub refresh_token_hash: String,
pub next_refresh_token_hash: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RotateRefreshSessionResult {
pub session: RefreshSessionRecord,
pub user: AuthUser,
}
@@ -63,15 +102,26 @@ pub enum PasswordEntryError {
PasswordHash(String),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RefreshSessionError {
MissingToken,
SessionNotFound,
SessionExpired,
UserNotFound,
Store(String),
}
#[derive(Clone, Debug)]
pub struct InMemoryPasswordUserStore {
inner: Arc<Mutex<InMemoryPasswordUserStoreState>>,
pub struct InMemoryAuthStore {
inner: Arc<Mutex<InMemoryAuthStoreState>>,
}
#[derive(Debug)]
struct InMemoryPasswordUserStoreState {
next_id: u64,
struct InMemoryAuthStoreState {
next_user_id: u64,
users_by_username: HashMap<String, StoredPasswordUser>,
sessions_by_id: HashMap<String, StoredRefreshSession>,
session_id_by_refresh_token_hash: HashMap<String, String>,
}
#[derive(Clone, Debug)]
@@ -80,13 +130,24 @@ struct StoredPasswordUser {
password_hash: String,
}
#[derive(Clone, Debug)]
struct StoredRefreshSession {
session: RefreshSessionRecord,
}
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryPasswordUserStore,
store: InMemoryAuthStore,
}
#[derive(Clone, Debug)]
pub struct RefreshSessionService {
store: InMemoryAuthStore,
refresh_session_ttl_days: u32,
}
impl PasswordEntryService {
pub fn new(store: InMemoryPasswordUserStore) -> Self {
pub fn new(store: InMemoryAuthStore) -> Self {
Self { store }
}
@@ -114,10 +175,7 @@ impl PasswordEntryService {
let password_hash = hash_password(&input.password)
.await
.map_err(|error| PasswordEntryError::PasswordHash(error.to_string()))?;
match self
.store
.create_user(username.clone(), password_hash.clone())
{
match self.store.create_user(username.clone(), password_hash) {
Ok(user) => Ok(PasswordEntryResult {
user,
created: true,
@@ -141,9 +199,7 @@ impl PasswordEntryService {
Err(CreateUserError::Store(message)) => Err(PasswordEntryError::Store(message)),
}
}
}
impl PasswordEntryService {
pub fn get_user_by_id(
&self,
user_id: &str,
@@ -154,18 +210,129 @@ impl PasswordEntryService {
}
}
impl Default for InMemoryPasswordUserStore {
impl RefreshSessionService {
pub fn new(store: InMemoryAuthStore, refresh_session_ttl_days: u32) -> Self {
Self {
store,
refresh_session_ttl_days,
}
}
pub fn create_session(
&self,
input: CreateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<CreateRefreshSessionResult, RefreshSessionError> {
self.store
.find_by_user_id(&input.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let session_id = format!("usess_{}", Uuid::new_v4().simple());
let expires_at = now
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
)?;
let expires_at_iso = expires_at
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
})?;
let session = RefreshSessionRecord {
session_id,
user_id: input.user_id,
refresh_token_hash: input.refresh_token_hash,
issued_by_provider: input.issued_by_provider,
expires_at: expires_at_iso,
revoked_at: None,
created_at: now_iso.clone(),
updated_at: now_iso.clone(),
last_seen_at: now_iso,
};
self.store.insert_session(session.clone())?;
Ok(CreateRefreshSessionResult { session })
}
pub fn rotate_session(
&self,
input: RotateRefreshSessionInput,
now: OffsetDateTime,
) -> Result<RotateRefreshSessionResult, RefreshSessionError> {
let refresh_token_hash = input.refresh_token_hash.trim().to_string();
if refresh_token_hash.is_empty() {
return Err(RefreshSessionError::MissingToken);
}
let session = self
.store
.find_session_by_refresh_token_hash(&refresh_token_hash)?
.ok_or(RefreshSessionError::SessionNotFound)?;
if session.session.revoked_at.is_some() {
return Err(RefreshSessionError::SessionNotFound);
}
let expires_at = OffsetDateTime::parse(
&session.session.expires_at,
&time::format_description::well_known::Rfc3339,
)
.map_err(|error| RefreshSessionError::Store(format!("refresh session 过期时间解析失败:{error}")))?;
if expires_at <= now {
return Err(RefreshSessionError::SessionExpired);
}
let user = self
.store
.find_by_user_id(&session.session.user_id)
.map_err(map_password_store_error)?
.ok_or(RefreshSessionError::UserNotFound)?;
let next_expires_at = now
.checked_add(Duration::days(i64::from(self.refresh_session_ttl_days)))
.ok_or_else(|| RefreshSessionError::Store("refresh session 过期时间计算溢出".to_string()))?;
let now_iso = now.format(&time::format_description::well_known::Rfc3339).map_err(
|error| RefreshSessionError::Store(format!("refresh session 时间格式化失败:{error}")),
)?;
let next_expires_at_iso = next_expires_at
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
RefreshSessionError::Store(format!("refresh session 过期时间格式化失败:{error}"))
})?;
let updated_session = self.store.rotate_session(
&session.session.session_id,
&session.session.refresh_token_hash,
input.next_refresh_token_hash,
next_expires_at_iso,
now_iso.clone(),
now_iso,
)?;
Ok(RotateRefreshSessionResult {
session: updated_session.session,
user: user.user,
})
}
}
impl Default for InMemoryAuthStore {
fn default() -> Self {
Self {
inner: Arc::new(Mutex::new(InMemoryPasswordUserStoreState {
next_id: 1,
inner: Arc::new(Mutex::new(InMemoryAuthStoreState {
next_user_id: 1,
users_by_username: HashMap::new(),
sessions_by_id: HashMap::new(),
session_id_by_refresh_token_hash: HashMap::new(),
})),
}
}
}
impl InMemoryPasswordUserStore {
impl InMemoryAuthStore {
fn find_by_username(
&self,
username: &str,
@@ -177,6 +344,22 @@ impl InMemoryPasswordUserStore {
Ok(state.users_by_username.get(username).cloned())
}
fn find_by_user_id(
&self,
user_id: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
.cloned())
}
fn create_user(
&self,
username: String,
@@ -191,8 +374,8 @@ impl InMemoryPasswordUserStore {
return Err(CreateUserError::AlreadyExists);
}
let user_id = format!("user_{:08}", state.next_id);
state.next_id += 1;
let user_id = format!("user_{:08}", state.next_user_id);
state.next_user_id += 1;
let user = AuthUser {
id: user_id,
@@ -215,20 +398,102 @@ impl InMemoryPasswordUserStore {
Ok(user)
}
fn find_by_user_id(
fn insert_session(
&self,
user_id: &str,
) -> Result<Option<StoredPasswordUser>, PasswordEntryError> {
session: RefreshSessionRecord,
) -> Result<(), RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
if state
.session_id_by_refresh_token_hash
.contains_key(&session.refresh_token_hash)
{
return Err(RefreshSessionError::Store(
"refresh token hash 已存在,无法重复创建会话".to_string(),
));
}
state.session_id_by_refresh_token_hash.insert(
session.refresh_token_hash.clone(),
session.session_id.clone(),
);
state.sessions_by_id.insert(
session.session_id.clone(),
StoredRefreshSession { session },
);
Ok(())
}
fn find_session_by_refresh_token_hash(
&self,
refresh_token_hash: &str,
) -> Result<Option<StoredRefreshSession>, RefreshSessionError> {
let state = self
.inner
.lock()
.map_err(|_| PasswordEntryError::Store("用户仓储锁已中毒".to_string()))?;
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
let Some(session_id) = state.session_id_by_refresh_token_hash.get(refresh_token_hash) else {
return Ok(None);
};
Ok(state
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
.cloned())
Ok(state.sessions_by_id.get(session_id).cloned())
}
fn rotate_session(
&self,
session_id: &str,
previous_refresh_token_hash: &str,
next_refresh_token_hash: String,
next_expires_at: String,
updated_at: String,
last_seen_at: String,
) -> Result<StoredRefreshSession, RefreshSessionError> {
let mut state = self
.inner
.lock()
.map_err(|_| RefreshSessionError::Store("会话仓储锁已中毒".to_string()))?;
if state
.session_id_by_refresh_token_hash
.contains_key(&next_refresh_token_hash)
{
return Err(RefreshSessionError::Store(
"新 refresh token hash 已存在,无法轮换".to_string(),
));
}
let current_refresh_token_hash = state
.sessions_by_id
.get(session_id)
.ok_or(RefreshSessionError::SessionNotFound)?
.session
.refresh_token_hash
.clone();
if current_refresh_token_hash != previous_refresh_token_hash {
return Err(RefreshSessionError::SessionNotFound);
}
state
.session_id_by_refresh_token_hash
.remove(previous_refresh_token_hash);
let stored = state
.sessions_by_id
.get_mut(session_id)
.ok_or(RefreshSessionError::SessionNotFound)?;
stored.session.refresh_token_hash = next_refresh_token_hash.clone();
stored.session.expires_at = next_expires_at;
stored.session.updated_at = updated_at;
stored.session.last_seen_at = last_seen_at;
let updated_session = stored.clone();
state
.session_id_by_refresh_token_hash
.insert(next_refresh_token_hash, updated_session.session.session_id.clone());
Ok(updated_session)
}
}
@@ -270,6 +535,32 @@ impl fmt::Display for PasswordEntryError {
impl Error for PasswordEntryError {}
impl fmt::Display for RefreshSessionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingToken => f.write_str("缺少刷新会话"),
Self::SessionNotFound | Self::SessionExpired | Self::UserNotFound => {
f.write_str("当前登录态已失效,请重新登录")
}
Self::Store(message) => f.write_str(message),
}
}
}
impl Error for RefreshSessionError {}
fn map_password_store_error(error: PasswordEntryError) -> RefreshSessionError {
match error {
PasswordEntryError::Store(message) => RefreshSessionError::Store(message),
PasswordEntryError::InvalidUsername
| PasswordEntryError::InvalidPasswordLength
| PasswordEntryError::InvalidCredentials
| PasswordEntryError::PasswordHash(_) => {
RefreshSessionError::Store("用户仓储读取失败".to_string())
}
}
}
fn normalize_username(raw_username: &str) -> Result<String, PasswordEntryError> {
let username = raw_username.trim().to_string();
let valid_length =
@@ -296,15 +587,25 @@ fn validate_password(password: &str) -> Result<(), PasswordEntryError> {
#[cfg(test)]
mod tests {
use platform_auth::hash_refresh_session_token;
use super::*;
fn build_service() -> PasswordEntryService {
PasswordEntryService::new(InMemoryPasswordUserStore::default())
fn build_store() -> InMemoryAuthStore {
InMemoryAuthStore::default()
}
fn build_password_service(store: InMemoryAuthStore) -> PasswordEntryService {
PasswordEntryService::new(store)
}
fn build_refresh_service(store: InMemoryAuthStore) -> RefreshSessionService {
RefreshSessionService::new(store, 30)
}
#[tokio::test]
async fn first_password_entry_creates_user() {
let service = build_service();
let service = build_password_service(build_store());
let result = service
.execute(PasswordEntryInput {
@@ -324,7 +625,8 @@ mod tests {
#[tokio::test]
async fn repeated_password_entry_reuses_same_user() {
let service = build_service();
let store = build_store();
let service = build_password_service(store);
let first = service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
@@ -348,7 +650,7 @@ mod tests {
#[tokio::test]
async fn repeated_password_entry_rejects_wrong_password() {
let service = build_service();
let service = build_password_service(build_store());
service
.execute(PasswordEntryInput {
username: "guest_001".to_string(),
@@ -370,7 +672,7 @@ mod tests {
#[tokio::test]
async fn invalid_username_returns_bad_request_error() {
let service = build_service();
let service = build_password_service(build_store());
let error = service
.execute(PasswordEntryInput {
@@ -382,4 +684,66 @@ mod tests {
assert_eq!(error, PasswordEntryError::InvalidUsername);
}
#[tokio::test]
async fn refresh_session_creation_and_rotation_keep_same_session_id() {
let store = build_store();
let password_service = build_password_service(store.clone());
let refresh_service = build_refresh_service(store);
let user = password_service
.execute(PasswordEntryInput {
username: "guest_002".to_string(),
password: "secret123".to_string(),
})
.await
.expect("seed login should succeed")
.user;
let now = OffsetDateTime::now_utc();
let first_token_hash = hash_refresh_session_token("refresh-token-01");
let created = refresh_service
.create_session(
CreateRefreshSessionInput {
user_id: user.id.clone(),
refresh_token_hash: first_token_hash.clone(),
issued_by_provider: AuthLoginMethod::Password,
},
now,
)
.expect("session should create");
let rotated = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: first_token_hash,
next_refresh_token_hash: hash_refresh_session_token("refresh-token-02"),
},
now + Duration::minutes(10),
)
.expect("session should rotate");
assert_eq!(rotated.user.id, user.id);
assert_eq!(rotated.session.session_id, created.session.session_id);
assert_ne!(
rotated.session.refresh_token_hash,
created.session.refresh_token_hash
);
}
#[tokio::test]
async fn refresh_session_rejects_unknown_token_hash() {
let store = build_store();
let refresh_service = build_refresh_service(store);
let error = refresh_service
.rotate_session(
RotateRefreshSessionInput {
refresh_token_hash: hash_refresh_session_token("missing"),
next_refresh_token_hash: hash_refresh_session_token("next"),
},
OffsetDateTime::now_utc(),
)
.expect_err("unknown token should fail");
assert_eq!(error, RefreshSessionError::SessionNotFound);
}
}

View File

@@ -6,6 +6,7 @@ license.workspace = true
[dependencies]
argon2 = "0.5"
sha2 = "0.10"
jsonwebtoken = "9"
rand_core = { version = "0.6", features = ["getrandom"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -6,6 +6,7 @@ use jsonwebtoken::{
};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime};
use uuid::Uuid;
@@ -403,6 +404,12 @@ pub fn create_refresh_session_token() -> String {
Uuid::new_v4().simple().to_string()
}
pub fn hash_refresh_session_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfig) -> String {
let mut parts = vec![
format!(
@@ -426,6 +433,22 @@ pub fn build_refresh_session_set_cookie(token: &str, config: &RefreshCookieConfi
parts.join("; ")
}
pub fn build_refresh_session_clear_cookie(config: &RefreshCookieConfig) -> String {
let mut parts = vec![
format!("{}=", config.cookie_name()),
format!("Path={}", config.cookie_path()),
"HttpOnly".to_string(),
format!("SameSite={}", config.cookie_same_site().as_str()),
"Max-Age=0".to_string(),
];
if config.cookie_secure() {
parts.push("Secure".to_string());
}
parts.join("; ")
}
impl fmt::Display for JwtError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
@@ -651,4 +674,25 @@ mod tests {
assert!(cookie.contains("SameSite=Lax"));
assert!(cookie.contains("Max-Age=2592000"));
}
#[test]
fn hash_refresh_session_token_matches_sha256_hex() {
let hash = hash_refresh_session_token("refresh-token-01");
assert_eq!(
hash,
"0b6901f0dcee3f50df4115ecb29214f7740f8173919f94cc1f5eb92ff2481ce8"
);
}
#[test]
fn build_refresh_session_clear_cookie_respects_config() {
let cookie = build_refresh_session_clear_cookie(&build_refresh_cookie_config());
assert!(cookie.contains("genarrative_refresh_session="));
assert!(cookie.contains("Path=/api/auth"));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Lax"));
assert!(cookie.contains("Max-Age=0"));
}
}