feat(auth): Telegram bot registration flow — /auth/telegram endpoint (#150)

* feat(auth): add /auth/telegram registration endpoint for bot-initiated login

When a user sends /start register to the Telegram bot, the bot sends an
inline button pointing to localhost:7788/auth/telegram?token=<token>.
This new GET handler consumes the one-time login token via the backend,
stores the resulting JWT as the app session, and returns a styled HTML
success/error page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: apply cargo fmt to telegram auth handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: apply CodeRabbit auto-fixes

Fixed 1 file(s) based on 2 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>

* update format

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
This commit is contained in:
Steven Enamakel
2026-03-31 20:18:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.6 CodeRabbit coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
parent 684e784896
commit 262390274d
+171
View File
@@ -87,6 +87,176 @@ pub fn default_state() -> AppState {
// --- HTTP server (Axum) ----------------------------------------------------
#[derive(Debug, serde::Deserialize)]
struct TelegramAuthQuery {
token: Option<String>,
}
fn success_html() -> String {
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenHuman &#8212; Connected</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.card { background: #1e293b; border-radius: 16px; padding: 48px; text-align: center; max-width: 420px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.3); }
.icon { font-size: 48px; margin-bottom: 16px; }
h1 { font-size: 24px; margin-bottom: 12px; color: #f8fafc; }
p { font-size: 16px; color: #94a3b8; line-height: 1.6; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#10004;</div>
<h1>Connected!</h1>
<p>Your Telegram account has been connected to OpenHuman. You can close this tab.</p>
</div>
</body>
</html>"#
.to_string()
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#x27;")
}
fn error_html(message: &str) -> String {
let escaped_message = escape_html(message);
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenHuman &#8212; Error</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; }}
.card {{ background: #1e293b; border-radius: 16px; padding: 48px; text-align: center; max-width: 420px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.3); }}
.icon {{ font-size: 48px; margin-bottom: 16px; }}
h1 {{ font-size: 24px; margin-bottom: 12px; color: #f8fafc; }}
p {{ font-size: 16px; color: #94a3b8; line-height: 1.6; }}
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9888;</div>
<h1>Something went wrong</h1>
<p>{escaped_message}</p>
</div>
</body>
</html>"#
)
}
async fn telegram_auth_handler(Query(query): Query<TelegramAuthQuery>) -> impl IntoResponse {
let html_response = |status: StatusCode, body: String| -> Response {
(
status,
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
body,
)
.into_response()
};
let token = match query
.token
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
Some(t) => t.to_string(),
None => {
return html_response(
StatusCode::BAD_REQUEST,
error_html("Missing token parameter. Send /start register to the bot again."),
)
}
};
log::info!("[auth:telegram] Received registration callback with token");
let config = match crate::openhuman::config::Config::load_or_init().await {
Ok(c) => c,
Err(e) => {
log::error!("[auth:telegram] Failed to load config: {e}");
return html_response(
StatusCode::INTERNAL_SERVER_ERROR,
error_html("Internal error. Please try again."),
);
}
};
let api_url = crate::api::config::effective_api_url(&config.api_url);
let client = match crate::api::rest::BackendOAuthClient::new(&api_url) {
Ok(c) => c,
Err(e) => {
log::error!("[auth:telegram] Failed to create API client: {e}");
return html_response(
StatusCode::INTERNAL_SERVER_ERROR,
error_html("Internal error. Please try again."),
);
}
};
let jwt_token = match client.consume_login_token(&token).await {
Ok(jwt) => jwt,
Err(e) => {
let error_str = e.to_string();
// Check if this is a client-side error (token validation) or server-side error
let is_client_error = error_str.contains("expired")
|| error_str.contains("invalid")
|| error_str.contains("not found")
|| error_str.contains("already used")
|| error_str.contains("401")
|| error_str.contains("400")
|| error_str.contains("404");
if is_client_error {
log::warn!("[auth:telegram] Token consumption failed (client error): {e}");
return html_response(
StatusCode::BAD_REQUEST,
error_html(
"This link has expired or was already used. Send /start register to the bot again.",
),
);
} else {
log::error!("[auth:telegram] Token consumption failed (server error): {e}");
return html_response(
StatusCode::INTERNAL_SERVER_ERROR,
error_html("Internal server error, please try again later."),
);
}
}
};
match crate::openhuman::credentials::ops::store_session(&config, &jwt_token, None, None).await {
Ok(outcome) => {
for msg in &outcome.logs {
log::info!("[auth:telegram] {msg}");
}
log::info!("[auth:telegram] Session stored successfully");
}
Err(e) => {
log::error!("[auth:telegram] Failed to store session: {e}");
return html_response(
StatusCode::INTERNAL_SERVER_ERROR,
error_html("Connected to Telegram but failed to save session. Please try again."),
);
}
}
html_response(StatusCode::OK, success_html())
}
pub fn build_core_http_router(socketio_enabled: bool) -> Router {
let router = Router::new()
.route("/", get(root_handler))
@@ -94,6 +264,7 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router {
.route("/schema", get(schema_handler))
.route("/events", get(events_handler))
.route("/rpc", post(rpc_handler))
.route("/auth/telegram", get(telegram_auth_handler))
.fallback(not_found_handler)
.layer(middleware::from_fn(http_request_log_middleware))
.layer(middleware::from_fn(cors_middleware))