diff --git a/api/auth/mod.rs b/api/auth/mod.rs index a67df05..899d1eb 100644 --- a/api/auth/mod.rs +++ b/api/auth/mod.rs @@ -16,6 +16,7 @@ pub mod rsa; pub mod verify_2fa; pub mod verify_email; pub mod verify_reset_password; +pub mod ws_token; use actix_web::web; @@ -27,6 +28,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) { .route("/login", web::post().to(login::handle)) .route("/logout", web::post().to(logout::handle)) .route("/me", web::get().to(me::handle)) + .route("/ws-token", web::post().to(ws_token::handle)) .route( "/register/email-code", web::post().to(register_email_code::handle), diff --git a/api/auth/ws_token.rs b/api/auth/ws_token.rs new file mode 100644 index 0000000..0d2a5cf --- /dev/null +++ b/api/auth/ws_token.rs @@ -0,0 +1,57 @@ +use std::collections::HashMap; + +use actix_web::{HttpResponse, web}; +use serde::Serialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +/// Response payload for `POST /auth/ws-token`. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct WsTokenResponse { + /// Short-lived JWT prefixed with "Bearer " for use in the Socket.IO CONNECT auth packet. + pub token: String, + /// Unix timestamp (seconds) when the token expires. + pub expires_at: i64, +} + +#[utoipa::path( + post, + path = "/api/v1/auth/ws-token", + tag = "Auth", + operation_id = "authWsToken", + summary = "Issue a short-lived WebSocket token", + description = "Issue a short-lived JWT (30 minutes) scoped to IM WebSocket access. \ + The token is signed by the appks signing key and can be verified by imks either \ + locally (via cached signing keys) or via RPC. The returned token should be passed \ + as `{ token: }` in the Socket.IO CONNECT auth packet. Requires an \ + authenticated session.", + responses( + (status = 200, description = "Token issued successfully.", body = ApiResponse), + (status = 401, description = "The current session is unauthenticated or the login state has expired.", body = ApiErrorResponse), + (status = 500, description = "Token issuance or Redis write failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, +) -> Result { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + + let issued = service + .internal_auth + .issue_token( + &user_uid.to_string(), + 1800, // 30-minute TTL (frontend refreshes every 25 min) + vec!["im:read".into(), "im:write".into()], + HashMap::new(), + ) + .await?; + + Ok(HttpResponse::Ok().json(ApiResponse::new(WsTokenResponse { + token: format!("Bearer {}", issued.access_token), + expires_at: issued.expires_at, + }))) +} diff --git a/api/openapi.rs b/api/openapi.rs index d0dd25f..9fabbb0 100644 --- a/api/openapi.rs +++ b/api/openapi.rs @@ -4,6 +4,7 @@ use crate::api::auth::regenerate_2fa_backup_codes::{ Regenerate2FABackupCodesRequest, Regenerate2FABackupCodesResponse, }; use crate::api::auth::register::RegisterResponse; +use crate::api::auth::ws_token::WsTokenResponse; use crate::api::issue::lock::LockIssueParams; use crate::api::issue::subscribers::MuteIssueParams; use crate::api::issue::transfer::TransferIssueParams; @@ -174,6 +175,7 @@ use crate::service::im::members::{InviteMemberParams, UpdateMemberParams}; crate::api::auth::disable_2fa::handle, crate::api::auth::regenerate_2fa_backup_codes::handle, crate::api::auth::change_password::change_password, + crate::api::auth::ws_token::handle, // User crate::api::user::get_account::get_account, crate::api::user::update_account::update_account, @@ -839,6 +841,8 @@ use crate::service::im::members::{InviteMemberParams, UpdateMemberParams}; NotifyUpdateTemplateParams, // Auth additions ChangePasswordParams, + ApiResponse, + WsTokenResponse, // User additions - Presence/Block/Follow ApiResponse, UserPresence, diff --git a/main.rs b/main.rs index 0bee516..c65aa13 100644 --- a/main.rs +++ b/main.rs @@ -75,6 +75,22 @@ async fn main() -> AppResult<()> { } }); + // Background task: rotate JWT signing keys every 10 minutes. + let token_service = service.internal_auth.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(600)); + // Skip the first immediate tick. + interval.tick().await; + loop { + interval.tick().await; + match token_service.rotate_if_needed().await { + Ok(true) => tracing::info!("signing key rotated"), + Ok(false) => tracing::debug!("signing key rotation not needed"), + Err(e) => tracing::error!(error = %e, "signing key rotation failed"), + } + } + }); + let host = config.get_env_or::("APP_HTTP_HOST", "0.0.0.0".to_string())?; let port = config.get_env_or::("APP_HTTP_PORT", 8000)?; let workers = config.get_env_or::( diff --git a/openapi.json b/openapi.json index 4c174ea..8f3efc4 100644 --- a/openapi.json +++ b/openapi.json @@ -1035,6 +1035,48 @@ } } }, + "/api/v1/auth/ws-token": { + "post": { + "tags": [ + "Auth" + ], + "summary": "Issue a short-lived WebSocket token", + "description": "Issue a short-lived JWT (30 minutes) scoped to IM WebSocket access. The token is signed by the appks signing key and can be verified by imks either locally (via cached signing keys) or via RPC. The returned token should be passed as `{ token: }` in the Socket.IO CONNECT auth packet. Requires an authenticated session.", + "operationId": "authWsToken", + "responses": { + "200": { + "description": "Token issued successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse_WsTokenResponse" + } + } + } + }, + "401": { + "description": "The current session is unauthenticated or the login state has expired.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + }, + "500": { + "description": "Token issuance or Redis write failed.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorResponse" + } + } + } + } + } + } + }, "/api/v1/im/workspaces/{workspace_name}/categories": { "get": { "tags": [ @@ -44098,6 +44140,33 @@ } } }, + "ApiResponse_WsTokenResponse": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "description": "Response payload for `POST /auth/ws-token`.", + "required": [ + "token", + "expires_at" + ], + "properties": { + "expires_at": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp (seconds) when the token expires." + }, + "token": { + "type": "string", + "description": "Short-lived JWT prefixed with \"Bearer \" for use in the Socket.IO CONNECT auth packet." + } + } + } + } + }, "ApiResponse_bool": { "type": "object", "required": [ @@ -55495,6 +55564,25 @@ "format": "uuid" } } + }, + "WsTokenResponse": { + "type": "object", + "description": "Response payload for `POST /auth/ws-token`.", + "required": [ + "token", + "expires_at" + ], + "properties": { + "expires_at": { + "type": "integer", + "format": "int64", + "description": "Unix timestamp (seconds) when the token expires." + }, + "token": { + "type": "string", + "description": "Short-lived JWT prefixed with \"Bearer \" for use in the Socket.IO CONNECT auth packet." + } + } } } },