runtime: require http auth

This commit is contained in:
Keisuke Hirata 2026-07-30 13:05:57 +09:00
parent 3dda06cbe3
commit ac6c8b275d
No known key found for this signature in database

View File

@ -116,6 +116,7 @@ pub async fn serve_runtime_http(
listener: TcpListener, listener: TcpListener,
local_token: Option<String>, local_token: Option<String>,
) -> Result<(), RuntimeHttpServerError> { ) -> Result<(), RuntimeHttpServerError> {
let local_token = local_token.ok_or(RuntimeHttpServerError::AuthRequired)?;
axum::serve(listener, runtime_http_router(runtime, local_token)).await?; axum::serve(listener, runtime_http_router(runtime, local_token)).await?;
Ok(()) Ok(())
} }
@ -127,9 +128,12 @@ pub async fn serve_runtime_http_with_auth(
local_token: Option<String>, local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>, auth: Option<RuntimeHttpAuthConfig>,
) -> Result<(), RuntimeHttpServerError> { ) -> Result<(), RuntimeHttpServerError> {
if local_token.is_none() && auth.is_none() {
return Err(RuntimeHttpServerError::AuthRequired);
}
axum::serve( axum::serve(
listener, listener,
runtime_http_router_with_auth(runtime, local_token, auth), runtime_http_router_with_optional_auth(runtime, local_token, auth),
) )
.await?; .await?;
Ok(()) Ok(())
@ -140,12 +144,20 @@ pub async fn serve_runtime_http_with_auth(
/// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local. /// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local.
/// The path contains only a Runtime-local `worker_id`; backend aliases are not /// The path contains only a Runtime-local `worker_id`; backend aliases are not
/// accepted or forwarded as Runtime authority. /// accepted or forwarded as Runtime authority.
pub fn runtime_http_router(runtime: Runtime, local_token: Option<String>) -> Router { pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router {
runtime_http_router_with_auth(runtime, local_token, None) runtime_http_router_with_optional_auth(runtime, Some(local_token), None)
} }
/// Build the REST router for an existing Runtime with signed capability-token auth. /// Build the REST router for an existing Runtime with signed capability-token auth.
pub fn runtime_http_router_with_auth( pub fn runtime_http_router_with_auth(
runtime: Runtime,
local_token: Option<String>,
auth: RuntimeHttpAuthConfig,
) -> Router {
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth))
}
fn runtime_http_router_with_optional_auth(
runtime: Runtime, runtime: Runtime,
local_token: Option<String>, local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>, auth: Option<RuntimeHttpAuthConfig>,
@ -1089,6 +1101,8 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
pub enum RuntimeHttpServerError { pub enum RuntimeHttpServerError {
#[error(transparent)] #[error(transparent)]
Runtime(#[from] RuntimeError), Runtime(#[from] RuntimeError),
#[error("Runtime HTTP server requires capability-token auth or a local bearer token")]
AuthRequired,
#[error("Runtime HTTP server I/O failed: {0}")] #[error("Runtime HTTP server I/O failed: {0}")]
Io(#[from] std::io::Error), Io(#[from] std::io::Error),
} }
@ -1216,7 +1230,7 @@ mod tests {
let (auth, signer) = auth_config_and_signer(); let (auth, signer) = auth_config_and_signer();
let token_a = token_for_workspace(&signer, "workspace-a"); let token_a = token_for_workspace(&signer, "workspace-a");
let token_b = token_for_workspace(&signer, "workspace-b"); let token_b = token_for_workspace(&signer, "workspace-b");
let app = runtime_http_router_with_auth(runtime, None, Some(auth)); let app = runtime_http_router_with_auth(runtime, None, auth);
let create_a = scoped_task_request("a", "workspace-a"); let create_a = scoped_task_request("a", "workspace-a");
let response = app let response = app
@ -1295,7 +1309,7 @@ mod tests {
.unwrap(); .unwrap();
let (auth, signer) = auth_config_and_signer(); let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]); let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, Some(auth)); let app = runtime_http_router_with_auth(runtime, None, auth);
let response = app let response = app
.oneshot(bearer_request( .oneshot(bearer_request(
@ -1316,7 +1330,7 @@ mod tests {
.unwrap(); .unwrap();
let (auth, signer) = auth_config_and_signer(); let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]); let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, Some(auth)); let app = runtime_http_router_with_auth(runtime, None, auth);
let create = scoped_task_request("a", "workspace-a"); let create = scoped_task_request("a", "workspace-a");
let response = app let response = app
@ -1413,16 +1427,18 @@ mod tests {
} }
} }
async fn json_request<T: Serialize>( async fn authed_json_request<T: Serialize>(
app: Router, app: Router,
method: Method, method: Method,
uri: &str, uri: &str,
token: &str,
body: &T, body: &T,
) -> axum::response::Response { ) -> axum::response::Response {
app.oneshot( app.oneshot(
Request::builder() Request::builder()
.method(method) .method(method)
.uri(uri) .uri(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json") .header(header::CONTENT_TYPE, "application/json")
.body(Body::from(serde_json::to_vec(body).unwrap())) .body(Body::from(serde_json::to_vec(body).unwrap()))
.unwrap(), .unwrap(),
@ -1443,6 +1459,24 @@ mod tests {
.unwrap() .unwrap()
} }
async fn authed_empty_request(
app: Router,
method: Method,
uri: &str,
token: &str,
) -> axum::response::Response {
app.oneshot(
Request::builder()
.method(method)
.uri(uri)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
async fn read_json<T: for<'de> Deserialize<'de>>(response: Response) -> T { async fn read_json<T: for<'de> Deserialize<'de>>(response: Response) -> T {
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&body).unwrap() serde_json::from_slice(&body).unwrap()
@ -1458,12 +1492,14 @@ mod tests {
"builtin:coder".to_string(), "builtin:coder".to_string(),
))) )))
.unwrap(); .unwrap();
let app = runtime_http_router(runtime.clone(), None); let token = "local-token";
let app = runtime_http_router(runtime.clone(), token.to_string());
let response = json_request( let response = authed_json_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
"/v1/workers", "/v1/workers",
token,
&task_request("rest"), &task_request("rest"),
) )
.await; .await;
@ -1475,77 +1511,84 @@ mod tests {
); );
let input = WorkerInput::user("hello from backend"); let input = WorkerInput::user("hello from backend");
let response = json_request( let response = authed_json_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/input", created.worker.worker_id), &format!("/v1/workers/{}/input", created.worker.worker_id),
token,
&input, &input,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let _input_ack: RuntimeHttpWorkerInputResponse = read_json(response).await; let _input_ack: RuntimeHttpWorkerInputResponse = read_json(response).await;
let response = empty_request( let response = authed_empty_request(
app.clone(), app.clone(),
Method::GET, Method::GET,
&format!("/v1/workers/{}", created.worker.worker_id), &format!("/v1/workers/{}", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let _detail: RuntimeHttpWorkerResponse = read_json(response).await; let _detail: RuntimeHttpWorkerResponse = read_json(response).await;
let response = empty_request( let response = authed_empty_request(
app.clone(), app.clone(),
Method::GET, Method::GET,
&format!("/v1/workers/{}/transcript", created.worker.worker_id), &format!("/v1/workers/{}/transcript", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = empty_request( let response = authed_empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/stop", created.worker.worker_id), &format!("/v1/workers/{}/stop", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let stop: RuntimeHttpWorkerLifecycleResponse = read_json(response).await; let stop: RuntimeHttpWorkerLifecycleResponse = read_json(response).await;
assert_eq!(stop.ack.worker_ref, created.worker.worker_ref); assert_eq!(stop.ack.worker_ref, created.worker.worker_ref);
let response = empty_request( let response = authed_empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/restore", created.worker.worker_id), &format!("/v1/workers/{}/restore", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let restored: RuntimeHttpWorkerResponse = read_json(response).await; let restored: RuntimeHttpWorkerResponse = read_json(response).await;
assert_eq!(restored.worker.status, WorkerStatus::Idle); assert_eq!(restored.worker.status, WorkerStatus::Idle);
let response = empty_request( let response = authed_empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/stop", created.worker.worker_id), &format!("/v1/workers/{}/stop", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let response = empty_request( let response = authed_empty_request(
app.clone(), app.clone(),
Method::POST, Method::POST,
&format!("/v1/workers/{}/cancel", created.worker.worker_id), &format!("/v1/workers/{}/cancel", created.worker.worker_id),
token,
) )
.await; .await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let cancel: RuntimeHttpWorkerLifecycleResponse = read_json(response).await; let cancel: RuntimeHttpWorkerLifecycleResponse = read_json(response).await;
assert_eq!(cancel.ack.worker_ref, created.worker.worker_ref); assert_eq!(cancel.ack.worker_ref, created.worker.worker_ref);
let response = empty_request(app.clone(), Method::GET, "/v1/workers").await; let response = authed_empty_request(app.clone(), Method::GET, "/v1/workers", token).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let workers: RuntimeHttpWorkersResponse = read_json(response).await; let workers: RuntimeHttpWorkersResponse = read_json(response).await;
assert_eq!(workers.workers.len(), 1); assert_eq!(workers.workers.len(), 1);
let response = empty_request(app, Method::GET, "/v1/runtime").await; let response = authed_empty_request(app, Method::GET, "/v1/runtime", token).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::OK);
let summary: RuntimeHttpSummaryResponse = read_json(response).await; let summary: RuntimeHttpSummaryResponse = read_json(response).await;
assert_eq!(summary.runtime.worker_count, 1); assert_eq!(summary.runtime.worker_count, 1);
@ -1554,7 +1597,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn local_token_placeholder_rejects_missing_bearer_token() { async fn local_token_placeholder_rejects_missing_bearer_token() {
let app = runtime_http_router(Runtime::new_memory(), Some("local-token".to_string())); let app = runtime_http_router(Runtime::new_memory(), "local-token".to_string());
let response = empty_request(app.clone(), Method::GET, "/v1/runtime").await; let response = empty_request(app.clone(), Method::GET, "/v1/runtime").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
@ -1577,8 +1620,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn runtime_errors_use_typed_rest_error_shape() { async fn runtime_errors_use_typed_rest_error_shape() {
let app = runtime_http_router(Runtime::new_memory(), None); let token = "local-token";
let response = empty_request(app, Method::GET, "/v1/workers/999").await; let app = runtime_http_router(Runtime::new_memory(), token.to_string());
let response = authed_empty_request(app, Method::GET, "/v1/workers/999", token).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: RuntimeHttpErrorResponse = read_json(response).await; let error: RuntimeHttpErrorResponse = read_json(response).await;
@ -1586,6 +1630,21 @@ mod tests {
assert!(error.error.message.contains("999")); assert!(error.error.message.contains("999"));
} }
#[tokio::test]
async fn serve_runtime_http_rejects_missing_auth_configuration() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_runtime_http(Runtime::new_memory(), listener, None)
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_runtime_http_with_auth(Runtime::new_memory(), listener, None, None)
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
}
#[test] #[test]
fn workdir_runtime_errors_preserve_diagnostic_code() { fn workdir_runtime_errors_preserve_diagnostic_code() {
let error = let error =
@ -1619,6 +1678,8 @@ mod ws_tests {
use std::sync::Arc; use std::sync::Arc;
use tokio_tungstenite::connect_async; use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header as ws_header;
struct WsBackend; struct WsBackend;
@ -1725,12 +1786,18 @@ mod ws_tests {
runtime runtime
.store_config_bundle(ws_test_bundle(ProfileSelector::RuntimeDefault)) .store_config_bundle(ws_test_bundle(ProfileSelector::RuntimeDefault))
.unwrap(); .unwrap();
let worker = runtime.create_worker(ws_create_request()).unwrap(); let worker = runtime
.create_worker_scoped("local", ws_create_request())
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();
tokio::spawn({ tokio::spawn({
let runtime = runtime.clone(); let runtime = runtime.clone();
async move { serve_runtime_http(runtime, listener, None).await.unwrap() } async move {
serve_runtime_http(runtime, listener, Some("local-token".to_string()))
.await
.unwrap()
}
}); });
( (
runtime, runtime,
@ -1742,6 +1809,15 @@ mod ws_tests {
) )
} }
fn authed_ws_request(url: &str) -> tokio_tungstenite::tungstenite::http::Request<()> {
let mut request = url.into_client_request().unwrap();
request.headers_mut().insert(
ws_header::AUTHORIZATION,
"Bearer local-token".parse().unwrap(),
);
request
}
async fn next_frame( async fn next_frame(
stream: &mut tokio_tungstenite::WebSocketStream< stream: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>, tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
@ -1757,7 +1833,7 @@ mod ws_tests {
#[tokio::test] #[tokio::test]
async fn protocol_ws_connect_sends_snapshot_and_live_worker_events() { async fn protocol_ws_connect_sends_snapshot_and_live_worker_events() {
let (runtime, worker_ref, url) = spawn_runtime_server().await; let (runtime, worker_ref, url) = spawn_runtime_server().await;
let (mut stream, _) = connect_async(&url).await.unwrap(); let (mut stream, _) = connect_async(authed_ws_request(&url)).await.unwrap();
assert!(matches!( assert!(matches!(
next_frame(&mut stream).await, next_frame(&mut stream).await,
@ -1799,9 +1875,8 @@ mod ws_tests {
) )
.unwrap(); .unwrap();
let (mut stream, _) = connect_async(format!("{url}?cursor={}", first.cursor)) let resume_url = format!("{url}?cursor={}", first.cursor);
.await let (mut stream, _) = connect_async(authed_ws_request(&resume_url)).await.unwrap();
.unwrap();
assert!(matches!( assert!(matches!(
next_frame(&mut stream).await, next_frame(&mut stream).await,
protocol::Event::Snapshot { .. } protocol::Event::Snapshot { .. }
@ -1824,13 +1899,16 @@ mod ws_tests {
#[tokio::test] #[tokio::test]
async fn protocol_ws_reports_malformed_cursor_and_method_frame() { async fn protocol_ws_reports_malformed_cursor_and_method_frame() {
let (_runtime, _worker_ref, url) = spawn_runtime_server().await; let (_runtime, _worker_ref, url) = spawn_runtime_server().await;
let (mut malformed, _) = connect_async(format!("{url}?cursor=bad")).await.unwrap(); let malformed_url = format!("{url}?cursor=bad");
let (mut malformed, _) = connect_async(authed_ws_request(&malformed_url))
.await
.unwrap();
assert!(matches!( assert!(matches!(
next_frame(&mut malformed).await, next_frame(&mut malformed).await,
protocol::Event::Error { .. } protocol::Event::Error { .. }
)); ));
let (mut stream, _) = connect_async(&url).await.unwrap(); let (mut stream, _) = connect_async(authed_ws_request(&url)).await.unwrap();
let _ = next_frame(&mut stream).await; let _ = next_frame(&mut stream).await;
stream.send(Message::Text("{}".into())).await.unwrap(); stream.send(Message::Text("{}".into())).await.unwrap();
assert!(matches!( assert!(matches!(