1use std::fmt;
42use std::future::pending;
43use std::io;
44use std::sync::Arc;
45use std::time::{Duration, SystemTime, SystemTimeError, UNIX_EPOCH};
46
47use axum::body::{Body, Bytes, to_bytes};
48use axum::extract::{DefaultBodyLimit, Path, Request, State};
49use axum::http::header::AUTHORIZATION;
50use axum::http::{HeaderMap, Method, StatusCode};
51use axum::response::{IntoResponse, Response};
52use axum::routing::{delete, get, post};
53use axum::serve as serve_router;
54use axum::{Json, Router};
55use reqwest::redirect::Policy;
56use reqwest::{Client, Error as ReqwestError};
57use serde_json::{from_slice, json};
58use tokio::net::TcpListener;
59use tokio::signal::ctrl_c;
60#[cfg(unix)]
61use tokio::signal::unix::{SignalKind, signal};
62use tokio::task::JoinHandle;
63use tokio::{select, spawn, time};
64use tracing::{info, warn};
65use url::Url;
66
67use ironflow_core::auth_proxy::{
68 AuthProxyError, AuthProxyRegistry, DEFAULT_UPSTREAM, TokenRejection, TokenRequest,
69 admin_key_matches, downstream_headers, error_body, extract_opaque_token, is_allowed_method,
70 is_allowed_path, upstream_headers,
71};
72
73pub const MIN_ADMIN_KEY_LEN: usize = 32;
75
76pub const DEFAULT_MAX_BODY_BYTES: usize = 32 * 1024 * 1024;
78
79const SHORT_ID_LEN: usize = 12;
81
82const UPSTREAM_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
85
86#[derive(Clone)]
98pub struct AuthProxyConfig {
99 pub upstream: Url,
101 pub admin_key: String,
103 pub max_body_bytes: usize,
105}
106
107impl fmt::Debug for AuthProxyConfig {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 f.debug_struct("AuthProxyConfig")
110 .field("upstream", &self.upstream.as_str())
111 .field("admin_key", &"<redacted>")
112 .field("max_body_bytes", &self.max_body_bytes)
113 .finish()
114 }
115}
116
117impl AuthProxyConfig {
118 pub fn new(admin_key: &str) -> Self {
128 assert!(
129 admin_key.len() >= MIN_ADMIN_KEY_LEN,
130 "the auth proxy admin key must be at least {MIN_ADMIN_KEY_LEN} characters"
131 );
132 Self {
133 upstream: Url::parse(DEFAULT_UPSTREAM).expect("DEFAULT_UPSTREAM is a valid URL"),
134 admin_key: admin_key.to_string(),
135 max_body_bytes: DEFAULT_MAX_BODY_BYTES,
136 }
137 }
138
139 pub fn with_upstream(mut self, upstream: Url) -> Self {
156 self.upstream = upstream;
157 self
158 }
159}
160
161#[derive(Clone)]
176pub struct AuthProxyState {
177 registry: AuthProxyRegistry,
178 config: Arc<AuthProxyConfig>,
179 http: Client,
180}
181
182impl AuthProxyState {
183 pub fn new(config: AuthProxyConfig) -> Result<Self, ReqwestError> {
197 let http = Client::builder()
198 .connect_timeout(UPSTREAM_CONNECT_TIMEOUT)
199 .redirect(Policy::none())
200 .build()?;
201 Ok(Self {
202 registry: AuthProxyRegistry::default(),
203 config: Arc::new(config),
204 http,
205 })
206 }
207
208 pub fn registry(&self) -> &AuthProxyRegistry {
214 &self.registry
215 }
216}
217
218pub fn router(state: AuthProxyState) -> Router {
235 let limit = state.config.max_body_bytes;
236 Router::new()
237 .route("/healthz", get(healthz))
238 .route("/admin/v1/tokens", post(issue_token))
239 .route("/admin/v1/tokens/{id}", delete(revoke_token))
240 .route("/admin/v1/runs/{run_id}/tokens", delete(revoke_run))
241 .fallback(relay)
242 .layer(DefaultBodyLimit::max(limit))
243 .with_state(state)
244}
245
246pub async fn serve(listener: TcpListener, state: AuthProxyState) -> io::Result<()> {
257 serve_router(listener, router(state))
258 .with_graceful_shutdown(shutdown_signal())
259 .await
260}
261
262pub fn spawn_purge(registry: AuthProxyRegistry, interval: Duration) -> JoinHandle<()> {
284 assert!(
285 !interval.is_zero(),
286 "purge interval must be greater than zero"
287 );
288 spawn(async move {
289 let mut ticker = time::interval(interval);
290 loop {
291 ticker.tick().await;
292 match now_unix() {
293 Ok(now) => {
294 let purged = registry.purge_expired(now);
295 if purged > 0 {
296 info!(purged, "expired auth proxy tokens purged");
297 }
298 }
299 Err(e) => warn!(error = %e, "clock before the unix epoch; purge skipped"),
300 }
301 }
302 })
303}
304
305async fn shutdown_signal() {
306 let interrupt = async {
307 if let Err(e) = ctrl_c().await {
308 warn!(error = %e, "cannot listen for ctrl-c");
309 pending::<()>().await;
310 }
311 };
312 #[cfg(unix)]
313 let terminate = async {
314 match signal(SignalKind::terminate()) {
315 Ok(mut sigterm) => {
316 sigterm.recv().await;
317 }
318 Err(e) => {
319 warn!(error = %e, "cannot listen for SIGTERM");
320 pending::<()>().await;
321 }
322 }
323 };
324 #[cfg(not(unix))]
325 let terminate = pending::<()>();
326 select! {
327 () = interrupt => {},
328 () = terminate => {},
329 }
330 info!("shutting down");
331}
332
333fn now_unix() -> Result<u64, SystemTimeError> {
334 SystemTime::now()
335 .duration_since(UNIX_EPOCH)
336 .map(|d| d.as_secs())
337}
338
339fn short_id(id: &str) -> &str {
340 id.get(..SHORT_ID_LEN).unwrap_or(id)
341}
342
343fn error_response(status: StatusCode, kind: &str, message: &str) -> Response {
344 (status, Json(error_body(kind, message))).into_response()
345}
346
347fn clock_error(e: &SystemTimeError) -> Response {
348 warn!(error = %e, "system clock is before the unix epoch");
349 error_response(
350 StatusCode::INTERNAL_SERVER_ERROR,
351 "api_error",
352 "auth proxy clock error",
353 )
354}
355
356fn admin_authorized(state: &AuthProxyState, headers: &HeaderMap) -> bool {
358 headers
359 .get(AUTHORIZATION)
360 .and_then(|value| value.to_str().ok())
361 .and_then(|value| value.strip_prefix("Bearer "))
362 .is_some_and(|key| admin_key_matches(&state.config.admin_key, key.trim()))
363}
364
365fn invalid_token(reason: &str, path: &str) -> Response {
367 warn!(reason, path = %path, "request with an invalid token rejected");
368 error_response(
369 StatusCode::UNAUTHORIZED,
370 "authentication_error",
371 "invalid or expired ironflow auth proxy token",
372 )
373}
374
375fn admin_unauthorized() -> Response {
376 warn!("admin request without a valid admin key");
377 error_response(
378 StatusCode::UNAUTHORIZED,
379 "authentication_error",
380 "invalid or missing admin key",
381 )
382}
383
384async fn healthz() -> &'static str {
385 "ok"
386}
387
388async fn issue_token(
389 State(state): State<AuthProxyState>,
390 headers: HeaderMap,
391 body: Bytes,
392) -> Response {
393 if !admin_authorized(&state, &headers) {
394 return admin_unauthorized();
395 }
396 let Ok(request) = from_slice::<TokenRequest>(&body) else {
399 warn!("token request body rejected");
400 return error_response(
401 StatusCode::BAD_REQUEST,
402 "invalid_request_error",
403 "invalid token request body",
404 );
405 };
406 let now = match now_unix() {
407 Ok(now) => now,
408 Err(e) => return clock_error(&e),
409 };
410 let run_id = request.run_id.clone();
411 let step = request.step.clone();
412 match state.registry.issue(request, now) {
413 Ok(issued) => {
414 info!(
415 token = %issued.short_id(),
416 run_id = %run_id,
417 step = %step,
418 "token issued"
419 );
420 (StatusCode::CREATED, Json(issued)).into_response()
421 }
422 Err(AuthProxyError::InvalidRequest(message)) => {
423 warn!(run_id = %run_id, step = %step, reason = %message, "token request refused");
424 error_response(StatusCode::BAD_REQUEST, "invalid_request_error", &message)
425 }
426 Err(e) => {
427 warn!(error = %e, "token issuance failed");
428 error_response(
429 StatusCode::INTERNAL_SERVER_ERROR,
430 "api_error",
431 "token issuance failed",
432 )
433 }
434 }
435}
436
437async fn revoke_token(
438 State(state): State<AuthProxyState>,
439 Path(id): Path<String>,
440 headers: HeaderMap,
441) -> Response {
442 if !admin_authorized(&state, &headers) {
443 return admin_unauthorized();
444 }
445 if state.registry.revoke(&id) {
446 info!(token = %short_id(&id), "token revoked");
447 StatusCode::NO_CONTENT.into_response()
448 } else {
449 error_response(StatusCode::NOT_FOUND, "not_found_error", "unknown token")
450 }
451}
452
453async fn revoke_run(
454 State(state): State<AuthProxyState>,
455 Path(run_id): Path<String>,
456 headers: HeaderMap,
457) -> Response {
458 if !admin_authorized(&state, &headers) {
459 return admin_unauthorized();
460 }
461 let revoked = state.registry.revoke_run(&run_id);
462 info!(run_id = %run_id, revoked, "run tokens revoked");
463 (StatusCode::OK, Json(json!({ "revoked": revoked }))).into_response()
464}
465
466async fn relay(State(state): State<AuthProxyState>, request: Request) -> Response {
467 let (parts, body) = request.into_parts();
468 let method = parts.method;
469 let path = parts.uri.path().to_string();
470
471 if parts.uri.authority().is_some() || method == Method::CONNECT {
474 warn!(method = %method, "request for another host refused");
475 return error_response(
476 StatusCode::FORBIDDEN,
477 "permission_error",
478 "only api.anthropic.com is reachable through this proxy",
479 );
480 }
481
482 let now = match now_unix() {
483 Ok(now) => now,
484 Err(e) => return clock_error(&e),
485 };
486 let Some(token) = extract_opaque_token(&parts.headers) else {
487 return invalid_token("missing", &path);
488 };
489 let grant = match state.registry.resolve(&token, now) {
490 Ok(grant) => grant,
491 Err(TokenRejection::Unknown) => return invalid_token("unknown", &path),
492 Err(TokenRejection::Expired) => return invalid_token("expired", &path),
493 };
494 let token = short_id(&grant.id);
495
496 if !is_allowed_path(&path) {
497 warn!(token = %token, path = %path, "path outside the API refused");
498 return error_response(
499 StatusCode::FORBIDDEN,
500 "permission_error",
501 "only the Anthropic API under /v1/ is reachable through this proxy",
502 );
503 }
504 if !is_allowed_method(&method) {
505 warn!(token = %token, method = %method, path = %path, "method refused");
506 return error_response(
507 StatusCode::METHOD_NOT_ALLOWED,
508 "invalid_request_error",
509 "only GET and POST are relayed",
510 );
511 }
512
513 let bytes = match to_bytes(body, state.config.max_body_bytes).await {
514 Ok(bytes) => bytes,
515 Err(e) => {
516 warn!(token = %token, error = %e, "request body rejected");
517 return error_response(
518 StatusCode::PAYLOAD_TOO_LARGE,
519 "request_too_large",
520 "request body too large or unreadable",
521 );
522 }
523 };
524
525 let mut url = state.config.upstream.clone();
526 url.set_path(&path);
527 url.set_query(parts.uri.query());
528 let upstream = state
529 .http
530 .request(method.clone(), url)
531 .headers(upstream_headers(&parts.headers, &grant.credential))
532 .body(bytes)
533 .send()
534 .await;
535 let upstream = match upstream {
536 Ok(upstream) => upstream,
537 Err(e) => {
538 warn!(token = %token, error = %e.without_url(), "upstream request failed");
539 return error_response(
540 StatusCode::BAD_GATEWAY,
541 "api_error",
542 "the Anthropic API is unreachable",
543 );
544 }
545 };
546
547 let status = upstream.status();
548 info!(
549 token = %token,
550 run_id = %grant.run_id,
551 step = %grant.step,
552 method = %method,
553 path = %path,
554 status = status.as_u16(),
555 "relayed"
556 );
557 let headers = downstream_headers(upstream.headers());
558 let mut response = Response::new(Body::from_stream(upstream.bytes_stream()));
559 *response.status_mut() = status;
560 *response.headers_mut() = headers;
561 response
562}