1use std::sync::Arc;
18
19use axum::body::Body;
20use axum::extract::State;
21use axum::http::{Request, StatusCode};
22use axum::middleware::Next;
23use axum::response::{IntoResponse, Response};
24use axum::Json;
25use chrono::Utc;
26use futu_auth::{CheckCtx, KeyStore, LimitOutcome, RuntimeCounters, Scope};
27
28#[derive(Clone)]
35pub struct AuthState {
36 pub key_store: Arc<KeyStore>,
37 pub counters: Arc<RuntimeCounters>,
38}
39
40impl AuthState {
41 pub fn new(key_store: Arc<KeyStore>, counters: Arc<RuntimeCounters>) -> Self {
42 Self {
43 key_store,
44 counters,
45 }
46 }
47}
48
49fn scope_for_path(path: &str) -> Option<Scope> {
57 const QOT: &[&str] = &[
59 "/api/global-state",
60 "/api/user-info",
61 "/api/delay-statistics",
62 "/api/subscribe",
63 "/api/sub-info",
64 "/api/quote",
65 "/api/kline",
66 "/api/orderbook",
67 "/api/broker",
68 "/api/ticker",
69 "/api/rt",
70 "/api/snapshot",
71 "/api/static-info",
72 "/api/plate-set",
73 "/api/plate-security",
74 "/api/reference",
75 "/api/owner-plate",
76 "/api/option-chain",
77 "/api/warrant",
78 "/api/capital-flow",
79 "/api/capital-distribution",
80 "/api/user-security",
81 "/api/stock-filter",
82 "/api/ipo-list",
83 "/api/future-info",
84 "/api/market-state",
85 "/api/history-kline",
86 ];
87 const ACC: &[&str] = &[
89 "/api/accounts",
90 "/api/funds",
91 "/api/positions",
92 "/api/orders",
93 "/api/order-fills",
94 "/api/history-orders",
95 "/api/history-order-fills",
96 "/api/max-trd-qtys",
97 "/api/margin-ratio",
98 "/api/order-fee",
99 "/api/sub-acc-push",
100 ];
101 const TRADE: &[&str] = &["/api/order", "/api/modify-order", "/api/unlock-trade"];
105
106 if TRADE.contains(&path) {
107 return Some(Scope::TradeReal);
108 }
109 if ACC.contains(&path) {
110 return Some(Scope::AccRead);
111 }
112 if QOT.contains(&path) {
113 return Some(Scope::QotRead);
114 }
115 None
117}
118
119pub async fn bearer_auth(
121 State(auth): State<AuthState>,
122 mut req: Request<Body>,
123 next: Next,
124) -> Response {
125 if !auth.key_store.is_configured() {
127 return next.run(req).await;
128 }
129
130 let path = req.uri().path();
131 if !path.starts_with("/api/") {
133 return next.run(req).await;
134 }
135
136 let token = req
138 .headers()
139 .get("authorization")
140 .and_then(|v| v.to_str().ok())
141 .and_then(|v| v.strip_prefix("Bearer ").map(|s| s.trim().to_string()));
142
143 let Some(token) = token else {
144 audit(path, None, "reject", "missing Authorization: Bearer");
145 return (
146 StatusCode::UNAUTHORIZED,
147 [("www-authenticate", "Bearer realm=\"futu-rest\"")],
148 Json(serde_json::json!({ "error": "missing Authorization: Bearer <api-key>" })),
149 )
150 .into_response();
151 };
152
153 let Some(rec) = auth.key_store.verify(&token) else {
154 audit(path, None, "reject", "invalid api key");
155 return (
156 StatusCode::UNAUTHORIZED,
157 Json(serde_json::json!({ "error": "invalid API key" })),
158 )
159 .into_response();
160 };
161
162 if rec.is_expired(Utc::now()) {
163 audit(path, Some(&rec.id), "reject", "key expired");
164 return (
165 StatusCode::UNAUTHORIZED,
166 Json(serde_json::json!({ "error": format!("API key {:?} expired", rec.id) })),
167 )
168 .into_response();
169 }
170
171 let Some(needed) = scope_for_path(path) else {
172 audit(path, Some(&rec.id), "reject", "unknown /api route");
176 return (
177 StatusCode::NOT_FOUND,
178 Json(serde_json::json!({
179 "error": format!("unknown API route {path:?}")
180 })),
181 )
182 .into_response();
183 };
184 if !rec.scopes.contains(&needed) {
185 audit(
186 path,
187 Some(&rec.id),
188 "reject",
189 &format!("missing scope {}", needed),
190 );
191 return (
192 StatusCode::FORBIDDEN,
193 Json(serde_json::json!({
194 "error": format!("API key {:?} missing scope {:?}", rec.id, needed.as_str())
195 })),
196 )
197 .into_response();
198 }
199
200 if needed == Scope::TradeReal {
203 let ctx = CheckCtx {
204 market: String::new(),
205 symbol: String::new(),
206 order_value: None,
207 trd_side: None,
208 };
209 if let LimitOutcome::Reject(reason) =
210 auth.counters
211 .check_and_commit(&rec.id, &rec.limits(), &ctx, Utc::now())
212 {
213 audit(path, Some(&rec.id), "reject", &format!("limit: {reason}"));
214 return (
215 StatusCode::TOO_MANY_REQUESTS,
216 Json(serde_json::json!({
217 "error": format!("limit check failed: {reason}")
218 })),
219 )
220 .into_response();
221 }
222 }
223
224 audit(path, Some(&rec.id), "allow", needed.as_str());
225
226 req.extensions_mut().insert(rec);
229
230 next.run(req).await
231}
232
233fn audit(path: &str, key_id: Option<&str>, result: &str, reason: &str) {
234 let key_id = key_id.unwrap_or("<none>");
235 if result == "reject" {
236 futu_auth::audit::reject("rest", path, key_id, reason);
237 } else {
238 futu_auth::audit::allow("rest", path, key_id, Some(reason));
239 }
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn scope_mapping() {
248 assert_eq!(scope_for_path("/api/quote"), Some(Scope::QotRead));
249 assert_eq!(scope_for_path("/api/accounts"), Some(Scope::AccRead));
250 assert_eq!(scope_for_path("/api/orders"), Some(Scope::AccRead));
251 assert_eq!(scope_for_path("/api/order"), Some(Scope::TradeReal));
252 assert_eq!(scope_for_path("/api/modify-order"), Some(Scope::TradeReal));
253 assert_eq!(scope_for_path("/api/unlock-trade"), Some(Scope::TradeReal));
254 assert_eq!(scope_for_path("/health"), None);
255 }
256
257 #[test]
258 fn unknown_api_path_fails_closed() {
259 assert_eq!(scope_for_path("/api/future-write-endpoint"), None);
262 assert_eq!(scope_for_path("/api/transfer-money"), None);
263 assert_eq!(scope_for_path("/api/"), None);
264 }
265
266 #[test]
267 fn all_known_routes_have_scopes() {
268 let known = [
271 "/api/global-state",
273 "/api/user-info",
274 "/api/delay-statistics",
275 "/api/subscribe",
276 "/api/sub-info",
277 "/api/quote",
278 "/api/kline",
279 "/api/orderbook",
280 "/api/broker",
281 "/api/ticker",
282 "/api/rt",
283 "/api/snapshot",
284 "/api/static-info",
285 "/api/plate-set",
286 "/api/plate-security",
287 "/api/reference",
288 "/api/owner-plate",
289 "/api/option-chain",
290 "/api/warrant",
291 "/api/capital-flow",
292 "/api/capital-distribution",
293 "/api/user-security",
294 "/api/stock-filter",
295 "/api/ipo-list",
296 "/api/future-info",
297 "/api/market-state",
298 "/api/history-kline",
299 "/api/accounts",
301 "/api/funds",
302 "/api/positions",
303 "/api/orders",
304 "/api/order-fills",
305 "/api/history-orders",
306 "/api/history-order-fills",
307 "/api/max-trd-qtys",
308 "/api/margin-ratio",
309 "/api/order-fee",
310 "/api/sub-acc-push",
311 "/api/order",
313 "/api/modify-order",
314 "/api/unlock-trade",
315 ];
316 for p in &known {
317 assert!(
318 scope_for_path(p).is_some(),
319 "route {p:?} is registered but not mapped to a Scope"
320 );
321 }
322 }
323}