feat: unify recommend anonymous runtime guest auth

- Route recommended runtime launches through shared runtime guest token handling
- Extend recommend-page anonymous play beyond jump-hop
- Add regression coverage for runtime guest launch clients
- Update docs to reflect the full anonymous-play matrix
This commit is contained in:
kdletters
2026-05-25 14:03:38 +08:00
parent 9a0bc6b129
commit c1dcf074bb
23 changed files with 820 additions and 236 deletions

View File

@@ -21,6 +21,9 @@ use url::Url;
pub const ACCESS_TOKEN_ALGORITHM: Algorithm = Algorithm::HS256;
pub const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: u64 = 2 * 60 * 60;
pub const DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS: u64 = 15 * 60;
pub const RUNTIME_GUEST_TOKEN_TYPE: &str = "runtime_guest";
pub const RUNTIME_GUEST_SCOPE_PUBLIC_PLAY: &str = "runtime:public-play";
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;
@@ -107,6 +110,21 @@ pub struct AccessTokenClaims {
pub exp: u64,
}
pub struct RuntimeGuestTokenClaimsInput {
pub subject: String,
pub scope: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeGuestTokenClaims {
pub iss: String,
pub sub: String,
pub typ: String,
pub scope: String,
pub iat: u64,
pub exp: u64,
}
// 统一承载 JWT 配置,避免 secret、issuer、ttl 在 api-server 与后续模块里散落。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JwtConfig {
@@ -417,6 +435,10 @@ impl JwtConfig {
pub fn access_token_ttl_seconds(&self) -> u64 {
self.access_token_ttl_seconds
}
pub fn runtime_guest_token_ttl_seconds(&self) -> u64 {
DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS
}
}
impl RefreshCookieSameSite {
@@ -1474,6 +1496,74 @@ impl AccessTokenClaims {
}
}
impl RuntimeGuestTokenClaims {
pub fn from_input(
input: RuntimeGuestTokenClaimsInput,
config: &JwtConfig,
issued_at: OffsetDateTime,
) -> Result<Self, JwtError> {
let subject = normalize_required_field(input.subject, "runtime guest JWT sub 不能为空")?;
let scope = normalize_required_field(input.scope, "runtime guest JWT scope 不能为空")?;
let issued_at_unix = issued_at.unix_timestamp();
if issued_at_unix < 0 {
return Err(JwtError::InvalidClaims("runtime guest JWT iat 不能早于 Unix epoch"));
}
let expires_at = issued_at
.checked_add(Duration::seconds(
i64::try_from(config.runtime_guest_token_ttl_seconds()).map_err(|_| {
JwtError::InvalidConfig("runtime guest JWT 过期时间超出 i64 上限")
})?,
))
.ok_or(JwtError::InvalidConfig("runtime guest JWT 过期时间计算溢出"))?;
let expires_at_unix = expires_at.unix_timestamp();
if expires_at_unix <= issued_at_unix {
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
}
let claims = Self {
iss: config.issuer().to_string(),
sub: subject,
typ: RUNTIME_GUEST_TOKEN_TYPE.to_string(),
scope,
iat: issued_at_unix as u64,
exp: expires_at_unix as u64,
};
claims.validate_for_config(config)?;
Ok(claims)
}
pub fn subject(&self) -> &str {
&self.sub
}
pub fn scope(&self) -> &str {
&self.scope
}
pub fn expires_at_unix(&self) -> u64 {
self.exp
}
pub fn validate_for_config(&self, config: &JwtConfig) -> Result<(), JwtError> {
if self.iss.trim() != config.issuer() {
return Err(JwtError::InvalidClaims(
"runtime guest JWT iss 与当前配置不一致",
));
}
normalize_required_field(self.sub.clone(), "runtime guest JWT sub 不能为空")?;
normalize_required_field(self.scope.clone(), "runtime guest JWT scope 不能为空")?;
if self.typ.trim() != RUNTIME_GUEST_TOKEN_TYPE {
return Err(JwtError::InvalidClaims("runtime guest JWT typ 非法"));
}
if self.exp <= self.iat {
return Err(JwtError::InvalidClaims("runtime guest JWT exp 必须晚于 iat"));
}
Ok(())
}
}
impl AccessTokenDeviceInfo {
pub fn normalize(self) -> Result<Self, JwtError> {
Ok(Self {
@@ -1526,6 +1616,26 @@ pub fn sign_access_token(
.map_err(|error| JwtError::SignFailed(format!("JWT 签发失败:{error}")))
}
pub fn sign_runtime_guest_token(
claims: &RuntimeGuestTokenClaims,
config: &JwtConfig,
) -> Result<String, JwtError> {
claims.validate_for_config(config)?;
let header = Header {
alg: ACCESS_TOKEN_ALGORITHM,
typ: Some("JWT".to_string()),
..Header::default()
};
encode(
&header,
claims,
&EncodingKey::from_secret(config.secret.as_bytes()),
)
.map_err(|error| JwtError::SignFailed(format!("runtime guest JWT 签发失败:{error}")))
}
pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessTokenClaims, JwtError> {
let token = token.trim();
if token.is_empty() {
@@ -1552,6 +1662,35 @@ pub fn verify_access_token(token: &str, config: &JwtConfig) -> Result<AccessToke
Ok(decoded.claims)
}
pub fn verify_runtime_guest_token(
token: &str,
config: &JwtConfig,
) -> Result<RuntimeGuestTokenClaims, JwtError> {
let token = token.trim();
if token.is_empty() {
return Err(JwtError::VerifyFailed("runtime guest JWT 不能为空".to_string()));
}
let mut validation = Validation::new(ACCESS_TOKEN_ALGORITHM);
validation.required_spec_claims = HashSet::from([
"exp".to_string(),
"iat".to_string(),
"iss".to_string(),
"sub".to_string(),
]);
validation.set_issuer(&[config.issuer()]);
let decoded = decode::<RuntimeGuestTokenClaims>(
token,
&DecodingKey::from_secret(config.secret.as_bytes()),
&validation,
)
.map_err(map_verify_error)?;
decoded.claims.validate_for_config(config)?;
Ok(decoded.claims)
}
pub fn read_refresh_session_token(
cookie_header: &str,
config: &RefreshCookieConfig,
@@ -2218,6 +2357,30 @@ mod tests {
.expect("real aliyun sms config should be valid")
}
#[test]
fn round_trip_sign_and_verify_runtime_guest_token() {
let config = build_jwt_config();
let issued_at = OffsetDateTime::now_utc();
let claims = RuntimeGuestTokenClaims::from_input(
RuntimeGuestTokenClaimsInput {
subject: "guest-runtime-123".to_string(),
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
},
&config,
issued_at,
)
.expect("runtime guest claims should build");
let token = sign_runtime_guest_token(&claims, &config).expect("token should sign");
let verified = verify_runtime_guest_token(&token, &config).expect("token should verify");
assert_eq!(verified, claims);
assert_eq!(verified.subject(), "guest-runtime-123");
assert_eq!(verified.scope(), RUNTIME_GUEST_SCOPE_PUBLIC_PLAY);
assert_eq!(verified.typ, RUNTIME_GUEST_TOKEN_TYPE);
assert_eq!(verified.expires_at_unix() - verified.iat, DEFAULT_RUNTIME_GUEST_TOKEN_TTL_SECONDS);
}
#[test]
fn round_trip_sign_and_verify_access_token() {
let config = build_jwt_config();