use axum::{ Json, extract::{Extension, Path, State}, http::StatusCode, }; use platform_auth::hash_refresh_session_token; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_kernel::{build_prefixed_uuid_id, new_uuid_simple_string}; use spacetime_client::{ ExternalApiKeyCreateRecordInput, ExternalApiKeyRecord, ExternalApiKeyRevokeRecordInput, SpacetimeClientError, }; use crate::{ api_response::json_success_body, auth::AuthenticatedAccessToken, editor_project::current_utc_micros, http_error::AppError, request_context::RequestContext, state::AppState, }; const EXTERNAL_API_KEY_ID_PREFIX: &str = "external-api-key-"; const EXTERNAL_API_KEY_SECRET_PREFIX: &str = "tnr_sk_"; const EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS: usize = 18; const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [ "editor:project", "editor:canvas", "editor:image-generate", "editor:asset", ]; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateRequest { name: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyPayload { key_id: String, name: String, key_prefix: String, scopes: Vec, created_at: String, last_used_at: Option, revoked_at: Option, updated_at: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyCreateResponse { api_key: String, key: ExternalApiKeyPayload, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyListResponse { keys: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ExternalApiKeyResponse { key: ExternalApiKeyPayload, } pub async fn list_external_api_keys( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let keys = state .spacetime_client() .list_external_api_keys(authenticated.claims().user_id().to_string()) .await .map_err(map_external_api_key_error)? .into_iter() .map(external_api_key_payload_from_record) .collect(); Ok(json_success_body( Some(&request_context), ExternalApiKeyListResponse { keys }, )) } pub async fn create_external_api_key( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { let raw_key = generate_external_api_key_secret(); let key_prefix = external_api_key_prefix(raw_key.as_str()); let key = state .spacetime_client() .create_external_api_key(ExternalApiKeyCreateRecordInput { key_id: build_prefixed_uuid_id(EXTERNAL_API_KEY_ID_PREFIX), owner_user_id: authenticated.claims().user_id().to_string(), name: payload .name .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "外部 API Key".to_string()), key_prefix, key_hash: hash_external_api_key(raw_key.as_str()), scopes: default_external_api_key_scopes(), now_micros: current_utc_micros(), }) .await .map_err(map_external_api_key_error)?; Ok(json_success_body( Some(&request_context), ExternalApiKeyCreateResponse { api_key: raw_key, key: external_api_key_payload_from_record(key), }, )) } pub async fn revoke_external_api_key( State(state): State, Path(key_id): Path, Extension(request_context): Extension, Extension(authenticated): Extension, ) -> Result, AppError> { let key = state .spacetime_client() .revoke_external_api_key(ExternalApiKeyRevokeRecordInput { key_id, owner_user_id: authenticated.claims().user_id().to_string(), revoked_at_micros: current_utc_micros(), }) .await .map_err(map_external_api_key_error)?; Ok(json_success_body( Some(&request_context), ExternalApiKeyResponse { key: external_api_key_payload_from_record(key), }, )) } pub(crate) fn hash_external_api_key(raw_key: &str) -> String { hash_refresh_session_token(raw_key) } pub(crate) fn default_external_api_key_scopes() -> Vec { EXTERNAL_API_KEY_SCOPES .iter() .map(|scope| (*scope).to_string()) .collect() } fn generate_external_api_key_secret() -> String { format!( "{EXTERNAL_API_KEY_SECRET_PREFIX}{}.{}", new_uuid_simple_string(), new_uuid_simple_string() ) } fn external_api_key_prefix(raw_key: &str) -> String { raw_key .chars() .take(EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS) .collect() } fn external_api_key_payload_from_record(record: ExternalApiKeyRecord) -> ExternalApiKeyPayload { ExternalApiKeyPayload { key_id: record.key_id, name: record.name, key_prefix: record.key_prefix, scopes: record.scopes, created_at: record.created_at, last_used_at: record.last_used_at, revoked_at: record.revoked_at, updated_at: record.updated_at, } } pub(crate) fn map_external_api_key_error(error: SpacetimeClientError) -> AppError { match error { SpacetimeClientError::Procedure(message) if message.contains("不存在") || message.contains("已失效") => { AppError::from_status(StatusCode::UNAUTHORIZED).with_details(json!({ "provider": "external-api-key", "message": message, })) } SpacetimeClientError::Procedure(message) if message.contains("无权") => { AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({ "provider": "external-api-key", "message": message, })) } SpacetimeClientError::Runtime(message) | SpacetimeClientError::Procedure(message) => { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "external-api-key", "message": message, })) } other => AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "spacetimedb", "message": other.to_string(), })), } } #[cfg(test)] mod tests { use super::*; #[test] fn generated_external_api_key_uses_public_prefix_only_for_display() { let raw_key = generate_external_api_key_secret(); let key_prefix = external_api_key_prefix(raw_key.as_str()); assert!(raw_key.starts_with(EXTERNAL_API_KEY_SECRET_PREFIX)); assert_eq!( key_prefix.chars().count(), EXTERNAL_API_KEY_PREFIX_VISIBLE_CHARS ); assert!(raw_key.starts_with(key_prefix.as_str())); assert_ne!(hash_external_api_key(raw_key.as_str()), raw_key); } #[test] fn default_external_api_key_scopes_cover_editor_openapi_v1() { let scopes = default_external_api_key_scopes(); assert_eq!( scopes, vec![ "editor:project".to_string(), "editor:canvas".to_string(), "editor:image-generate".to_string(), "editor:asset".to_string(), ] ); } }