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

@@ -9,9 +9,13 @@ use axum::{
response::Response,
};
use platform_auth::{
AccessTokenClaims, AuthProvider, BindingStatus, read_refresh_session_token, verify_access_token,
AccessTokenClaims, AuthProvider, BindingStatus, RuntimeGuestTokenClaims,
RuntimeGuestTokenClaimsInput, RUNTIME_GUEST_SCOPE_PUBLIC_PLAY, read_refresh_session_token,
sign_runtime_guest_token, verify_access_token, verify_runtime_guest_token,
};
use serde_json::{Value, json};
use shared_contracts::auth::RuntimeGuestTokenResponse;
use shared_kernel::{format_rfc3339, new_uuid_simple_string};
use time::OffsetDateTime;
use tracing::warn;
@@ -34,6 +38,18 @@ pub struct RefreshSessionToken {
token: String,
}
#[derive(Clone, Debug)]
pub enum RuntimePrincipal {
User(AuthenticatedAccessToken),
Guest(RuntimeGuestTokenClaims),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimePrincipalKind {
User,
Guest,
}
impl AuthenticatedAccessToken {
pub fn new(claims: AccessTokenClaims) -> Self {
Self { claims }
@@ -54,6 +70,66 @@ impl RefreshSessionToken {
}
}
impl RuntimePrincipal {
pub fn subject(&self) -> &str {
match self {
Self::User(authenticated) => authenticated.claims().user_id(),
Self::Guest(claims) => claims.subject(),
}
}
pub fn kind(&self) -> RuntimePrincipalKind {
match self {
Self::User(_) => RuntimePrincipalKind::User,
Self::Guest(_) => RuntimePrincipalKind::Guest,
}
}
}
impl RuntimePrincipalKind {
pub fn as_str(self) -> &'static str {
match self {
Self::User => "user",
Self::Guest => "guest",
}
}
}
pub async fn issue_runtime_guest_token(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
let issued_at = OffsetDateTime::now_utc();
let claims = RuntimeGuestTokenClaims::from_input(
RuntimeGuestTokenClaimsInput {
subject: format!("guest-runtime-{}", new_uuid_simple_string()),
scope: RUNTIME_GUEST_SCOPE_PUBLIC_PLAY.to_string(),
},
state.auth_jwt_config(),
issued_at,
)
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
let token = sign_runtime_guest_token(&claims, state.auth_jwt_config()).map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string())
})?;
let expires_at = OffsetDateTime::from_unix_timestamp(claims.expires_at_unix() as i64)
.ok()
.and_then(|value| format_rfc3339(value).ok())
.unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string());
Ok(json_success_body(
Some(&request_context),
RuntimeGuestTokenResponse {
token,
expires_at,
subject: claims.subject().to_string(),
scope: claims.scope().to_string(),
},
))
}
pub async fn require_bearer_auth(
State(state): State<AppState>,
mut request: Request,
@@ -70,29 +146,70 @@ pub async fn require_bearer_auth(
Ok(response)
}
pub async fn attach_optional_bearer_auth(
pub async fn require_runtime_principal_auth(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
if let Some(authenticated) = authenticate_request(&state, &request)? {
request.extensions_mut().insert(authenticated.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(authenticated);
return Ok(response);
let Some(principal) = authenticate_runtime_principal(&state, &request)? else {
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
};
request.extensions_mut().insert(principal.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(principal);
Ok(response)
}
fn authenticate_runtime_principal(
state: &AppState,
request: &Request,
) -> Result<Option<RuntimePrincipal>, AppError> {
if !request.headers().contains_key(AUTHORIZATION) {
return Ok(None);
}
Ok(next.run(request).await)
match authenticate_request(state, request) {
Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))),
Ok(None) => Ok(None),
Err(_) => {
let bearer_token = extract_bearer_token(request.headers())?;
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config())
.map_err(|error| {
warn!(
%request_id,
error = %error,
"runtime guest JWT 校验失败"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
})?;
if claims.scope() != RUNTIME_GUEST_SCOPE_PUBLIC_PLAY {
warn!(
%request_id,
scope = %claims.scope(),
"runtime guest JWT scope 非法"
);
return Err(AppError::from_status(StatusCode::UNAUTHORIZED));
}
Ok(Some(RuntimePrincipal::Guest(claims)))
}
}
}
fn authenticate_request(
state: &AppState,
request: &Request,
) -> Result<Option<AuthenticatedAccessToken>, AppError> {
if allows_internal_forwarded_auth(request.uri().path())
&& let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers())
{
return Ok(Some(AuthenticatedAccessToken::new(claims)));
if allows_internal_forwarded_auth(request.uri().path()) {
if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) {
return Ok(Some(AuthenticatedAccessToken::new(claims)));
}
}
if !request.headers().contains_key(AUTHORIZATION) {

View File

@@ -24,7 +24,7 @@ use std::{collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
http_error::AppError,
generated_asset_sheets::{
GeneratedAssetSheetPromptInput, build_generated_asset_sheet_prompt,
@@ -51,7 +51,6 @@ const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation";
const JUMP_HOP_RUNTIME_PROVIDER: &str = "jump-hop-runtime";
const JUMP_HOP_TEMPLATE_ID: &str = "jump-hop";
const JUMP_HOP_TEMPLATE_NAME: &str = "跳一跳";
const JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID: &str = "anonymous-runtime";
const JUMP_HOP_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/jump-hop/runs";
pub async fn create_jump_hop_session(
@@ -231,15 +230,13 @@ pub async fn get_jump_hop_runtime_work(
pub async fn start_jump_hop_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let authenticated = maybe_authenticated.as_ref().map(|Extension(authenticated)| authenticated);
let owner_user_id = authenticated
.map(|authenticated| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
.spacetime_client()
.start_jump_hop_run(payload, owner_user_id.clone())
@@ -256,7 +253,7 @@ pub async fn start_jump_hop_run(
&state,
&request_context,
build_jump_hop_work_play_tracking_draft(
authenticated,
&principal,
run.profile_id.clone(),
JUMP_HOP_RUNTIME_RUNS_ROUTE,
)
@@ -265,7 +262,7 @@ pub async fn start_jump_hop_run(
.profile_id(run.profile_id.clone())
.extra(json!({
"runStatus": run.status,
"isAnonymous": maybe_authenticated.is_none(),
"principalKind": principal_kind,
})),
)
.await;
@@ -280,15 +277,12 @@ pub async fn jump_hop_run_jump(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopJumpRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let owner_user_id = maybe_authenticated
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let run = state
.spacetime_client()
.jump_hop_run_jump(run_id, owner_user_id, payload)
@@ -311,15 +305,12 @@ pub async fn restart_jump_hop_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
maybe_authenticated: Option<Extension<AuthenticatedAccessToken>>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<JumpHopRestartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
let owner_user_id = maybe_authenticated
.as_ref()
.map(|Extension(authenticated)| authenticated.claims().user_id().to_string())
.unwrap_or_else(|| JUMP_HOP_ANONYMOUS_RUNTIME_OWNER_ID.to_string());
let owner_user_id = principal.subject().to_string();
let run = state
.spacetime_client()
.restart_jump_hop_run(run_id, owner_user_id, payload)
@@ -711,16 +702,11 @@ async fn persist_jump_hop_generated_image_asset(
}
fn build_jump_hop_work_play_tracking_draft(
authenticated: Option<&AuthenticatedAccessToken>,
principal: &RuntimePrincipal,
work_id: impl Into<String>,
source_route: &'static str,
) -> WorkPlayTrackingDraft {
match authenticated {
Some(authenticated) => {
WorkPlayTrackingDraft::new("jump-hop", work_id, authenticated, source_route)
}
None => WorkPlayTrackingDraft::anonymous("jump-hop", work_id, source_route),
}
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
}

View File

@@ -4,7 +4,7 @@ use axum::{
};
use crate::{
auth::{attach_refresh_session_token, require_bearer_auth},
auth::{attach_refresh_session_token, issue_runtime_guest_token, require_bearer_auth},
auth_me::auth_me,
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::{auth_sessions, revoke_auth_session},
@@ -65,6 +65,7 @@ pub fn router(state: AppState) -> Router<AppState> {
attach_refresh_session_token,
)),
)
.route("/api/auth/runtime-guest-token", post(issue_runtime_guest_token))
.route("/api/auth/phone/send-code", post(send_phone_code))
.route("/api/auth/phone/login", post(phone_login))
.route("/api/auth/wechat/start", get(start_wechat_login))

View File

@@ -4,7 +4,7 @@ use axum::{
};
use crate::{
auth::{attach_optional_bearer_auth, require_bearer_auth},
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
get_jump_hop_runtime_work, get_jump_hop_session, jump_hop_run_jump, list_jump_hop_gallery,
@@ -58,21 +58,21 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/jump-hop/runs",
post(start_jump_hop_run).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_optional_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/jump-hop/runs/{run_id}/jump",
post(jump_hop_run_jump).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_optional_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/jump-hop/runs/{run_id}/restart",
post(restart_jump_hop_run).route_layer(middleware::from_fn_with_state(
state.clone(),
attach_optional_bearer_auth,
require_runtime_principal_auth,
)),
)
.route("/api/runtime/jump-hop/gallery", get(list_jump_hop_gallery))

View File

@@ -4,7 +4,7 @@ use axum::{
};
use crate::{
auth::require_bearer_auth,
auth::{require_bearer_auth, require_runtime_principal_auth},
state::AppState,
wooden_fish::{
checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
@@ -52,21 +52,21 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/runtime/wooden-fish/runs",
post(start_wooden_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/wooden-fish/runs/{run_id}/checkpoint",
post(checkpoint_wooden_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(
"/api/runtime/wooden-fish/runs/{run_id}/finish",
post(finish_wooden_fish_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
require_runtime_principal_auth,
)),
)
.route(

View File

@@ -32,7 +32,7 @@ use crate::generated_image_assets::{
};
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
http_error::AppError,
openai_image_generation::{
DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client,
@@ -229,14 +229,14 @@ pub async fn get_wooden_fish_runtime_work(
pub async fn start_wooden_fish_run(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<WoodenFishStartRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = wooden_fish_json(payload, &request_context, WOODEN_FISH_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
let run = state
.spacetime_client()
.start_wooden_fish_run(payload, authenticated.claims().user_id().to_string())
.start_wooden_fish_run(payload, principal.subject().to_string())
.await
.map_err(|error| {
wooden_fish_error_response(
@@ -256,7 +256,7 @@ pub async fn checkpoint_wooden_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<WoodenFishCheckpointRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
@@ -265,7 +265,7 @@ pub async fn checkpoint_wooden_fish_run(
.spacetime_client()
.checkpoint_wooden_fish_run(
run_id,
authenticated.claims().user_id().to_string(),
principal.subject().to_string(),
payload,
)
.await
@@ -287,7 +287,7 @@ pub async fn finish_wooden_fish_run(
State(state): State<AppState>,
Path(run_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Extension(principal): Extension<RuntimePrincipal>,
payload: Result<Json<WoodenFishFinishRunRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &run_id, "runId")?;
@@ -296,7 +296,7 @@ pub async fn finish_wooden_fish_run(
.spacetime_client()
.finish_wooden_fish_run(
run_id,
authenticated.claims().user_id().to_string(),
principal.subject().to_string(),
payload,
)
.await

View File

@@ -2,7 +2,7 @@ use module_runtime::RuntimeTrackingScopeKind;
use serde_json::{Value, json};
use crate::{
auth::AuthenticatedAccessToken,
auth::{AuthenticatedAccessToken, RuntimePrincipal},
request_context::RequestContext,
state::{AppState, PuzzleApiState},
tracking::{TrackingEventDraft, record_tracking_event_after_success},
@@ -36,12 +36,28 @@ impl WorkPlayTrackingDraft {
)
}
pub(crate) fn anonymous(
pub(crate) fn runtime_principal(
play_type: &'static str,
work_id: impl Into<String>,
principal: &RuntimePrincipal,
source_route: &'static str,
) -> Self {
Self::with_user_id(play_type, work_id, None, source_route)
match principal {
RuntimePrincipal::User(authenticated) => {
Self::new(play_type, work_id, authenticated, source_route)
}
RuntimePrincipal::Guest(claims) => Self::with_user_id(
play_type,
work_id,
Some(claims.subject().to_string()),
source_route,
)
.extra(json!({
"principalKind": "guest",
"guestSubject": claims.subject(),
"guestScope": claims.scope(),
})),
}
}
fn with_user_id(