diff --git a/.gitignore b/.gitignore index 6a044b4..81c4fed 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ target # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ TODO.md +.DS_Store diff --git a/cockpit/src/App.tsx b/cockpit/src/App.tsx index a5e3572..9253c54 100644 --- a/cockpit/src/App.tsx +++ b/cockpit/src/App.tsx @@ -1,9 +1,10 @@ -import { useState, lazy, Suspense } from "react"; -import { AppShell, Group, NavLink, Button, Modal, TextInput, Stack, Text, Loader } from "@mantine/core"; +import { useEffect, useState, lazy, Suspense } from "react"; +import { AppShell, Group, NavLink, Button, Center, Text, Loader } from "@mantine/core"; import { Routes, Route, NavLink as RouterNavLink, Navigate, useLocation } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; -import { getToken, setToken } from "./api/client"; +import { api, setToken } from "./api/client"; import { Logo } from "./components/Logo"; +import { LoginPage } from "./pages/Login"; import { PrincipalsPage } from "./pages/Principals"; import { PortfoliosPage } from "./pages/Portfolios"; import { AccountsPage } from "./pages/Accounts"; @@ -13,6 +14,7 @@ import { BlotterPage } from "./pages/Blotter"; import { ReconciliationPage } from "./pages/Reconciliation"; import { InstrumentsPage } from "./pages/Instruments"; import { DataFeedsPage } from "./pages/DataFeeds"; +import { ApiPage } from "./pages/Api"; // Heavy bundles (Scalar, Mermaid) — only load when their doc page is opened. const ApiDocsPage = lazy(() => import("./pages/ApiDocs").then((m) => ({ default: m.ApiDocsPage }))); @@ -22,6 +24,7 @@ const ArchitecturePage = lazy(() => const NAV = [ { to: "/principals", label: "Principals" }, + { to: "/tokens", label: "API" }, { to: "/portfolios", label: "Portfolios" }, { to: "/accounts", label: "Accounts" }, { to: "/broker-connections", label: "Broker connections" }, @@ -38,30 +41,6 @@ const DOCS_NAV = [ { to: "/api-docs", label: "API docs" }, ]; -function TokenModal({ opened, onClose }: { opened: boolean; onClose: () => void }) { - const [value, setValue] = useState(getToken()); - return ( - - - - The OMS admin token (OMS_ADMIN_TOKEN). Leave blank in dev when - OMS_ADMIN_AUTH_ENABLED=false. Stored in this browser only. - - setValue(e.currentTarget.value)} - /> - - - - - - - ); -} - function ConnectionDot() { const { data: ok } = useQuery({ queryKey: ["health"], @@ -76,8 +55,8 @@ function ConnectionDot() { ); } -export function App() { - const [tokenOpen, setTokenOpen] = useState(false); +/** The authenticated console. */ +function Console({ onLogout }: { onLogout: () => void }) { const { pathname } = useLocation(); return ( @@ -86,8 +65,8 @@ export function App() { - @@ -118,6 +97,7 @@ export function App() { } /> } /> + } /> } /> } /> } /> @@ -144,7 +124,66 @@ export function App() { /> - setTokenOpen(false)} /> ); } + +type Status = "checking" | "authed" | "login"; + +/** + * Auth gate. Probes an authed endpoint rather than just checking for a token, so + * dev with OMS_ADMIN_AUTH_ENABLED=false still enters straight through (no token + * needed), while a real deployment shows the login screen on 401/403. + */ +export function App() { + const [status, setStatus] = useState("checking"); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(); + + async function probe(): Promise { + try { + await api.get("/admin/principals"); + return true; + } catch { + return false; + } + } + + useEffect(() => { + probe().then((ok) => setStatus(ok ? "authed" : "login")); + }, []); + + useEffect(() => { + const onUnauthorized = () => setStatus("login"); + window.addEventListener("oms:unauthorized", onUnauthorized); + return () => window.removeEventListener("oms:unauthorized", onUnauthorized); + }, []); + + if (status === "checking") { + return ( +
+ +
+ ); + } + + if (status === "login") { + return ( + { + setToken(token); + setError(undefined); + setChecking(true); + const ok = await probe(); + setChecking(false); + if (ok) setStatus("authed"); + else setError("Invalid token"); + }} + /> + ); + } + + return { setToken(""); setStatus("login"); }} />; +} diff --git a/cockpit/src/api/client.ts b/cockpit/src/api/client.ts index e672e25..7189d2f 100644 --- a/cockpit/src/api/client.ts +++ b/cockpit/src/api/client.ts @@ -32,6 +32,12 @@ async function request(method: string, path: string, body?: unknown): Promise }); if (!res.ok) { + // Auth failed/expired: drop the stored token and let the app fall back to the + // login gate (which listens for this event). + if (res.status === 401 || res.status === 403) { + setToken(""); + window.dispatchEvent(new Event("oms:unauthorized")); + } const text = await res.text().catch(() => ""); throw new ApiError(res.status, text || `${method} ${path} failed (${res.status})`); } diff --git a/cockpit/src/api/types.ts b/cockpit/src/api/types.ts index 76a7283..62c43ca 100644 --- a/cockpit/src/api/types.ts +++ b/cockpit/src/api/types.ts @@ -81,6 +81,24 @@ export interface Grant { updated_at: string; } +// A single bearer token (Databento-style) minted under a principal. `token` once. +export interface TradingTokenCreated { + token: string; + key_id: string; + principal_id: string; + portfolio_id: string | null; + label: string | null; +} + +export interface TradingTokenRow { + key_id: string; + label: string | null; + principal_id: string; + principal_code: string; + principal_name: string | null; + created_at: string; +} + export interface RiskLimit { id: string; portfolio_id: string; diff --git a/cockpit/src/pages/Api.tsx b/cockpit/src/pages/Api.tsx new file mode 100644 index 0000000..f8ac39c --- /dev/null +++ b/cockpit/src/pages/Api.tsx @@ -0,0 +1,293 @@ +import { useState } from "react"; +import { + Box, + Button, + CopyButton, + Group, + Loader, + Select, + Stack, + Text, + TextInput, + Tooltip, +} from "@mantine/core"; +import { Link } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; +import { api } from "../api/client"; +import { useList, notifyError, notifyOk } from "../api/hooks"; +import type { Principal, Portfolio, TradingTokenCreated, TradingTokenRow } from "../api/types"; + +const PATH = "/admin/trading-tokens"; + +// Brand tokens (theme.ts): green "bids", amber "asks", near-black terminal ink. +const C = { + panel: "#1a1e26", + inset: "#0b0d10", + border: "#2a2f38", + ink: "#eff1f4", + muted: "#9aa3af", + faint: "#6b7280", + green: "#22ae6c", + amber: "#ce9a3b", +}; + +function ago(iso: string): string { + const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.round(s / 60)}m`; + if (s < 86400) return `${Math.round(s / 3600)}h`; + return `${Math.round(s / 86400)}d`; +} + +function Eyebrow({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** A single `$`-prompted terminal line with an inline copy affordance. */ +function CmdLine({ value, display }: { value: string; display: React.ReactNode }) { + return ( + + $ + + {display} + + + {({ copied, copy }) => ( + + + {copied ? "copied" : "copy"} + + + )} + + + ); +} + +export function ApiPage() { + const qc = useQueryClient(); + const { data: tokens, isLoading } = useList(PATH); + const { data: principals } = useList("/admin/principals"); + const { data: portfolios } = useList("/admin/portfolios"); + + const [principalId, setPrincipalId] = useState(null); + const [portfolioId, setPortfolioId] = useState(null); + const [label, setLabel] = useState(""); + const [creating, setCreating] = useState(false); + const [created, setCreated] = useState(null); + + const principalOpts = (principals ?? []).map((p) => ({ + value: p.id, + label: p.display_name ? `${p.display_name} · ${p.code}` : p.code, + })); + const portfolioOpts = (portfolios ?? []).map((p) => ({ + value: p.id, + label: p.name ? `${p.name} · ${p.code}` : p.code, + })); + + const canGenerate = !!principalId; + + async function generate() { + setCreating(true); + try { + const res = await api.post(PATH, { + principal_id: principalId, + portfolio_id: portfolioId, + label: label.trim() || null, + }); + setCreated(res); + setLabel(""); + qc.invalidateQueries({ queryKey: [PATH] }); + notifyOk("Token created"); + } catch (e) { + notifyError(e); + } finally { + setCreating(false); + } + } + + async function revoke(keyId: string) { + try { + await api.del(`${PATH}/${keyId}`); + qc.invalidateQueries({ queryKey: [PATH] }); + if (created?.key_id === keyId) setCreated(null); + notifyOk("Token revoked"); + } catch (e) { + notifyError(e); + } + } + + const curl = created + ? `curl "$OMS_URL/orders/submit" -H "Authorization: Bearer ${created.token}" -H "Content-Type: application/json" -d @order.json` + : ""; + + const inputStyles = { + input: { background: C.inset, borderColor: C.border, color: C.ink, fontSize: 13 }, + label: { color: C.muted, fontSize: 11, marginBottom: 4 }, + }; + + return ( + +
+ API access + + API tokens + + + A token is a single bearer credential for a bot or strategy. It belongs to a + principal; what it can trade follows that principal's portfolio grants. Point + clients at your OMS host and send{" "} + Authorization: Bearer <token>. + +
+ + {/* ── Create ─────────────────────────────────────────── */} + + + Create token + + + {principalOpts.length === 0 ? ( + + No principals yet. Create one on the{" "} + Principals tab, + then mint its tokens here. + + ) : ( + + + + )} + + setLabel(e.currentTarget.value)} + styles={inputStyles} + style={{ flex: 1 }} + /> + + + + + + {/* ── Reveal (terminal readout) ──────────────────────── */} + {created && ( + + + New credential + + + + shown once — copy now + + + + + OMS_TOKEN={created.token}} + /> + + + + + )} + + {/* ── Ledger ─────────────────────────────────────────── */} + + + Active tokens + {tokens && {tokens.length}} + + + {isLoading ? ( + + ) : (tokens ?? []).length === 0 ? ( + No tokens yet. Create one above. + ) : ( + <> + + Label + Principal + Key id + Age + + + {(tokens ?? []).map((t, i) => ( + + + + + {t.label ?? unlabeled} + + + + {t.principal_name ?? t.principal_code} + + + + {t.key_id} + + + {ago(t.created_at)} + + revoke(t.key_id)} + style={{ cursor: "pointer", letterSpacing: 0.5 }} + > + revoke + + + + ))} + + )} + + +
+ ); +} diff --git a/cockpit/src/pages/Login.tsx b/cockpit/src/pages/Login.tsx new file mode 100644 index 0000000..7d7fa45 --- /dev/null +++ b/cockpit/src/pages/Login.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { Box, Button, Group, PasswordInput, Stack, Text, TextInput } from "@mantine/core"; +import { Logo } from "../components/Logo"; + +// Brand tokens (theme.ts): green "bids", amber "asks", near-black terminal ink. +const C = { + bg: "#0d1014", + panel: "#1a1e26", + inset: "#0b0d10", + border: "#2a2f38", + ink: "#eff1f4", + muted: "#9aa3af", + faint: "#6b7280", + green: "#22ae6c", + amber: "#ce9a3b", +}; + +/** + * Console sign-in. The username is cosmetic (single shared admin); the password is + * the OMS admin password, sent as a bearer to /admin. `onSubmit` gets the password; + * the caller verifies it. + */ +export function LoginPage({ + onSubmit, + error, + checking, +}: { + onSubmit: (password: string) => void; + error?: string; + checking?: boolean; +}) { + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState(""); + + const inputStyles = { + input: { background: C.inset, borderColor: C.border, color: C.ink, fontSize: 13 }, + label: { color: C.muted, fontSize: 11, marginBottom: 4, letterSpacing: 0.3 }, + }; + + return ( + + + {/* depth-ladder accent: green "bids" → amber "asks" */} + + + + + + Console + + + +
+ + Sign in + + + Enter your admin password to open the console. + +
+ +
{ + e.preventDefault(); + onSubmit(password); + }} + > + + setUsername(e.currentTarget.value)} + autoComplete="username" + styles={inputStyles} + /> + setPassword(e.currentTarget.value)} + error={error} + autoComplete="current-password" + data-autofocus + styles={inputStyles} + /> + + +
+ + + Set by OMS_ADMIN_PASSWORD + +
+
+
+ ); +} diff --git a/readme.md b/readme.md index e1cd4a3..621ba67 100644 --- a/readme.md +++ b/readme.md @@ -43,6 +43,20 @@ skip auto-sync, or `OMS_BOOTSTRAP=off` when infrastructure is provisioned elsewh The SPY fixture seeds `alpaca-paper` + a test user (`test-trader-key` : `test-secret`), so you can place a paper order immediately. Admin webapp: `cd cockpit && npm install && npm run dev`. +## Auth + +- **Cockpit login** — the console is gated by a single password: `OMS_ADMIN_TOKEN` + (enabled via `OMS_ADMIN_AUTH_ENABLED=true`). Enter it on the login screen; it's sent + as a bearer to `/admin`. Set a strong random value for any real deployment. +- **Trading tokens** — generate one on the cockpit's *Trading tokens* page (or + `POST /admin/trading-tokens`). A token belongs to a **principal** (a trader / + strategy / service) and is a single copy-once bearer string used by API clients as + `Authorization: Bearer `. What it can trade comes from the principal's + portfolio grants (`can_trade`), so you can mint several tokens under one principal + to rotate credentials without re-permissioning. Revoke any token anytime. +- The legacy HTTP Basic form (`key_id:secret`, e.g. the `test-trader` dev user) still + works on the trading routes. + ### Manual setup (optional) The bootstrap just orchestrates the same idempotent make targets, if you'd rather run diff --git a/src/admin.rs b/src/admin.rs index 8676d82..a1590c2 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -803,26 +803,8 @@ pub async fn register_principal_key( Path(principal_id): Path, Json(payload): Json, ) -> Result, AdminError> { - let key_id = format!("ak_{}", Uuid::new_v4().simple()); - - let mut raw = [0u8; 32]; - raw[..16].copy_from_slice(Uuid::new_v4().as_bytes()); - raw[16..].copy_from_slice(Uuid::new_v4().as_bytes()); - let plaintext_secret = format!("sk_{}", general_purpose::URL_SAFE_NO_PAD.encode(raw)); - - info!(principal_id = %principal_id, key_id = %key_id, "admin register key"); - - let secret_to_hash = plaintext_secret.clone(); - let secret_hash = tokio::task::spawn_blocking(move || bcrypt::hash(&secret_to_hash, 12)) - .await - .map_err(|_| AdminError { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: "hash task failed".to_string(), - })? - .map_err(|_| AdminError { - status: StatusCode::INTERNAL_SERVER_ERROR, - message: "failed to hash secret".to_string(), - })?; + info!(principal_id = %principal_id, "admin register key"); + let KeyMaterial { key_id, plaintext_secret, secret_hash } = generate_key_material().await?; let mut record = sqlx::query_as::<_, ApiKeyRecord>( r#" @@ -880,6 +862,173 @@ pub async fn revoke_principal_key( Ok(StatusCode::NO_CONTENT) } +// ── Trading tokens ──────────────────────────────────────────────────────────── +// +// A "trading token" is an ordinary `api_key` presented to the trading routes as a +// single bearer string `"{key_id}.{secret}"` (see `auth::extract_trading_credentials`). +// The endpoints here make it a one-click, ready-to-trade credential: generating one +// can auto-provision the principal + portfolio + `can_trade` grant it needs. + +/// Freshly generated key material, before it's persisted. +struct KeyMaterial { + key_id: String, + plaintext_secret: String, + secret_hash: String, +} + +/// Generate a new `(key_id, secret)` and its bcrypt hash. `key_id` = `ak_`, +/// secret = `sk_`. Hashing runs off the async pool. +async fn generate_key_material() -> Result { + let key_id = format!("ak_{}", Uuid::new_v4().simple()); + let mut raw = [0u8; 32]; + raw[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + raw[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + let plaintext_secret = format!("sk_{}", general_purpose::URL_SAFE_NO_PAD.encode(raw)); + + let to_hash = plaintext_secret.clone(); + let secret_hash = tokio::task::spawn_blocking(move || bcrypt::hash(&to_hash, 12)) + .await + .map_err(|_| AdminError { status: StatusCode::INTERNAL_SERVER_ERROR, message: "hash task failed".to_string() })? + .map_err(|_| AdminError { status: StatusCode::INTERNAL_SERVER_ERROR, message: "failed to hash secret".to_string() })?; + + Ok(KeyMaterial { key_id, plaintext_secret, secret_hash }) +} + +#[derive(Debug, Deserialize, utoipa::ToSchema)] +pub struct CreateTradingToken { + /// Mint the token under this existing principal (the trader/strategy/service it + /// belongs to). Create principals on the Principals admin surface first. + pub principal_id: Uuid, + /// Optionally entitle the principal to trade this portfolio (adds a `can_trade` + /// grant). Omit if the principal is already granted, or to grant separately. + pub portfolio_id: Option, + /// Human label for this key (shown in the token list). Optional. + pub label: Option, +} + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct TradingTokenCreated { + /// The single bearer token — `Authorization: Bearer `. Shown once. + pub token: String, + pub key_id: String, + pub principal_id: Uuid, + pub portfolio_id: Option, + pub label: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow, utoipa::ToSchema)] +pub struct TradingTokenRow { + pub key_id: String, + pub label: Option, + pub principal_id: Uuid, + pub principal_code: String, + pub principal_name: Option, + pub created_at: DateTime, +} + +#[utoipa::path( + post, path = "/admin/trading-tokens", tag = "admin", + request_body = CreateTradingToken, + responses( + (status = 200, description = "Created — single bearer token included once", body = TradingTokenCreated), + (status = 422, description = "Auto-provision needs an active broker connection"), + ), + security(("bearer_token" = [])) +)] +pub async fn create_trading_token( + State(state): State, + Json(payload): Json, +) -> Result, AdminError> { + let label = payload.label.clone(); + let principal_id = payload.principal_id; + info!(%principal_id, portfolio_id = ?payload.portfolio_id, "admin create trading token"); + + let material = generate_key_material().await?; + let mut tx = state.pool().begin().await.map_err(map_db_error)?; + + // Optionally entitle the principal to trade a portfolio. (The token belongs to + // this principal; its grants decide what the token can trade.) + if let Some(portfolio_id) = payload.portfolio_id { + sqlx::query( + "INSERT INTO principal_portfolio_grant \ + (id, principal_id, portfolio_id, can_trade, can_view, can_allocate) \ + VALUES ($1, $2, $3, true, true, false) \ + ON CONFLICT (principal_id, portfolio_id) DO NOTHING", + ) + .bind(Uuid::new_v4()) + .bind(principal_id) + .bind(portfolio_id) + .execute(&mut *tx) + .await + .map_err(map_db_error)?; + } + + // Persist the api key under the principal. + sqlx::query("INSERT INTO api_key (principal_id, key_id, secret_hash, name) VALUES ($1, $2, $3, $4)") + .bind(principal_id) + .bind(&material.key_id) + .bind(&material.secret_hash) + .bind(label.clone()) + .execute(&mut *tx) + .await + .map_err(map_db_error)?; + + tx.commit().await.map_err(map_db_error)?; + + Ok(Json(TradingTokenCreated { + token: format!("{}.{}", material.key_id, material.plaintext_secret), + key_id: material.key_id, + principal_id, + portfolio_id: payload.portfolio_id, + label, + })) +} + +#[utoipa::path( + get, path = "/admin/trading-tokens", tag = "admin", + responses((status = 200, description = "Active trading tokens", body = [TradingTokenRow])), + security(("bearer_token" = [])) +)] +pub async fn list_trading_tokens( + State(state): State, +) -> Result>, AdminError> { + // Every api key is a trading credential (auth_middleware accepts it); list them + // with the principal they belong to. + let rows = sqlx::query_as::<_, TradingTokenRow>( + "SELECT k.key_id, k.name AS label, k.principal_id, \ + p.code AS principal_code, p.display_name AS principal_name, k.created_at \ + FROM api_key k JOIN principal p ON p.id = k.principal_id \ + WHERE k.revoked_at IS NULL \ + ORDER BY k.created_at DESC", + ) + .fetch_all(state.pool()) + .await + .map_err(map_db_error)?; + Ok(Json(rows)) +} + +#[utoipa::path( + delete, path = "/admin/trading-tokens/{key_id}", tag = "admin", + params(("key_id" = String, Path, description = "Token key id")), + responses((status = 204, description = "Revoked"), (status = 404, description = "Not found")), + security(("bearer_token" = [])) +)] +pub async fn revoke_trading_token( + State(state): State, + Path(key_id): Path, +) -> Result { + info!(key_id = %key_id, "admin revoke trading token"); + let result = sqlx::query("UPDATE api_key SET revoked_at = now() WHERE key_id = $1 AND revoked_at IS NULL") + .bind(&key_id) + .execute(state.pool()) + .await + .map_err(map_db_error)?; + if result.rows_affected() == 0 { + return Err(AdminError::not_found("token")); + } + Ok(StatusCode::NO_CONTENT) +} + // ── Grant management ────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, utoipa::ToSchema)] diff --git a/src/auth.rs b/src/auth.rs index 2d5056e..2622e87 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -15,15 +15,39 @@ pub struct AuthContext { pub principal_id: Uuid, } -// validate oms token pair +/// Authenticate a trading request and inject `AuthContext { principal_id }`. +/// +/// Accepts two equivalent credential forms carrying the same `(key_id, secret)`: +/// - HTTP **Basic** `key_id:secret` — the original form (kept for back-compat, incl. +/// the dev fixture and any existing callers). +/// - **Bearer** `key_id.secret` — a single copy-paste "trading token" (Databento +/// style). Split on the first `.` (neither `ak_…` key ids nor `sk_…` secrets +/// contain a dot). +/// +/// Both resolve through the same DB lookup + bcrypt verify ([`verify_key`]). pub async fn auth_middleware( State(state): State, mut req: Request, next: Next, ) -> Result { - let (key_id, secret) = extract_basic_credentials(req.headers())?; - - // db lookup for secret of principal + let (key_id, secret) = extract_trading_credentials(req.headers())?; + + let principal_id = verify_key(state.pool(), &key_id, &secret) + .await? + .ok_or_else(unauthorized)?; + + req.extensions_mut().insert(AuthContext { principal_id }); + Ok(next.run(req).await) +} + +/// Look up an active api key by `key_id` and bcrypt-verify `secret`. Returns the +/// owning `principal_id` on success, `None` if the key is unknown/revoked or the +/// secret doesn't match. `Err` only for infrastructure failures (DB / task join). +pub async fn verify_key( + pool: &sqlx::PgPool, + key_id: &str, + secret: &str, +) -> Result, Response> { let row = sqlx::query_as::<_, (Uuid, String)>( r#" SELECT k.principal_id, k.secret_hash @@ -32,25 +56,23 @@ pub async fn auth_middleware( WHERE k.key_id = $1 AND k.revoked_at IS NULL AND p.status = 'ACTIVE' "#, ) - .bind(&key_id) - .fetch_optional(state.pool()) + .bind(key_id) + .fetch_optional(pool) .await .map_err(|_| service_unavailable())?; - let (principal_id, secret_hash) = row.ok_or_else(unauthorized)?; + let Some((principal_id, secret_hash)) = row else { + return Ok(None); + }; // bcrypt is CPU-bound — run it off the async thread pool + let secret = secret.to_string(); let valid = tokio::task::spawn_blocking(move || bcrypt::verify(&secret, &secret_hash)) .await .map_err(|_| service_unavailable())? .map_err(|_| unauthorized())?; - if !valid { - return Err(unauthorized()); - } - - req.extensions_mut().insert(AuthContext { principal_id }); - Ok(next.run(req).await) + Ok(valid.then_some(principal_id)) } pub async fn admin_middleware( @@ -71,24 +93,34 @@ pub async fn admin_middleware( Ok(next.run(req).await) } -fn extract_basic_credentials(headers: &header::HeaderMap) -> Result<(String, String), Response> { +/// Extract `(key_id, secret)` from either credential form: +/// - `Authorization: Basic base64(key_id:secret)` +/// - `Authorization: Bearer key_id.secret` (single trading token) +fn extract_trading_credentials(headers: &header::HeaderMap) -> Result<(String, String), Response> { let value = headers .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or_else(unauthorized)?; - let encoded = value.strip_prefix("Basic ").ok_or_else(unauthorized)?; - - let decoded = STANDARD.decode(encoded).map_err(|_| unauthorized())?; - let credentials = String::from_utf8(decoded).map_err(|_| unauthorized())?; - - let (key_id, secret) = credentials.split_once(':').ok_or_else(unauthorized)?; + if let Some(encoded) = value.strip_prefix("Basic ") { + let decoded = STANDARD.decode(encoded).map_err(|_| unauthorized())?; + let credentials = String::from_utf8(decoded).map_err(|_| unauthorized())?; + let (key_id, secret) = credentials.split_once(':').ok_or_else(unauthorized)?; + if key_id.is_empty() || secret.is_empty() { + return Err(unauthorized()); + } + return Ok((key_id.to_string(), secret.to_string())); + } - if key_id.is_empty() || secret.is_empty() { - return Err(unauthorized()); + if let Some(token) = value.strip_prefix("Bearer ") { + let (key_id, secret) = token.split_once('.').ok_or_else(unauthorized)?; + if key_id.is_empty() || secret.is_empty() { + return Err(unauthorized()); + } + return Ok((key_id.to_string(), secret.to_string())); } - Ok((key_id.to_string(), secret.to_string())) + Err(unauthorized()) } fn extract_bearer_token(headers: &header::HeaderMap) -> Result { diff --git a/src/main.rs b/src/main.rs index 33fd6f7..75fc37f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,9 @@ mod fix; admin::register_principal_key, admin::list_principal_keys, admin::revoke_principal_key, + admin::create_trading_token, + admin::list_trading_tokens, + admin::revoke_trading_token, admin::create_grant, admin::list_grants, admin::update_grant, @@ -120,6 +123,7 @@ mod fix; CreateAccount, UpdateAccount, CreateBrokerConnection, UpdateBrokerConnection, CreateKey, ApiKeyRecord, + admin::CreateTradingToken, admin::TradingTokenCreated, admin::TradingTokenRow, Grant, CreateGrant, UpdateGrant, admin::RiskLimit, admin::CreateRiskLimit, admin::UpdateRiskLimit, admin::InstrumentSummary, admin::FeedSummary, @@ -260,14 +264,17 @@ async fn serve() { .map(|v| v.to_lowercase() != "false") .unwrap_or(true); + // The admin console login password. `OMS_ADMIN_PASSWORD` is the canonical name; + // `OMS_ADMIN_TOKEN` is still accepted for back-compat. let admin_token = if !admin_auth_enabled { String::new() } else { - env::var("OMS_ADMIN_TOKEN") + env::var("OMS_ADMIN_PASSWORD") .ok() .filter(|v| !v.is_empty()) + .or_else(|| env::var("OMS_ADMIN_TOKEN").ok().filter(|v| !v.is_empty())) .unwrap_or_else(|| { - error!("OMS_ADMIN_TOKEN is not set"); + error!("OMS_ADMIN_PASSWORD is not set"); std::process::exit(1); }) }; @@ -538,6 +545,14 @@ async fn serve() { "/admin/principals/:id/keys/:key_id", axum::routing::delete(admin::revoke_principal_key), ) + .route( + "/admin/trading-tokens", + post(admin::create_trading_token).get(admin::list_trading_tokens), + ) + .route( + "/admin/trading-tokens/:key_id", + axum::routing::delete(admin::revoke_trading_token), + ) .route( "/admin/portfolios", post(admin::create_portfolio).get(admin::list_portfolios),