1use std::fmt;
9use std::sync::Arc;
10
11use axum::extract::{MatchedPath, Request, State};
12use axum::http::{HeaderMap, Method, header};
13use axum::middleware::Next;
14use axum::response::{IntoResponse, Response};
15use prospero_core::Scope;
16use prospero_core::auth::TokenSet;
17use rand::RngCore as _;
18
19use crate::error::ApiError;
20
21pub mod handlers;
22pub mod session;
23
24pub struct SessionKey(Vec<u8>);
26
27impl SessionKey {
28 pub const MIN_LEN: usize = 32;
30
31 pub fn random() -> SessionKey {
33 let mut bytes = vec![0u8; Self::MIN_LEN];
34 rand::thread_rng().fill_bytes(&mut bytes);
35 SessionKey(bytes)
36 }
37
38 pub fn from_bytes(bytes: Vec<u8>) -> Result<SessionKey, String> {
40 if bytes.len() < Self::MIN_LEN {
41 return Err(format!(
42 "session key must be at least {} bytes (got {})",
43 Self::MIN_LEN,
44 bytes.len()
45 ));
46 }
47 Ok(SessionKey(bytes))
48 }
49
50 pub(crate) fn as_bytes(&self) -> &[u8] {
51 &self.0
52 }
53}
54
55impl fmt::Debug for SessionKey {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 f.write_str("SessionKey(<redacted>)")
58 }
59}
60
61#[derive(Debug)]
63pub struct AuthState {
64 tokens: Option<TokenSet>,
65 session_key: SessionKey,
66 cookie_secure: bool,
67}
68
69impl AuthState {
70 pub fn disabled() -> AuthState {
73 AuthState {
74 tokens: None,
75 session_key: SessionKey::random(),
76 cookie_secure: false,
77 }
78 }
79
80 pub fn enabled(tokens: TokenSet, session_key: SessionKey, cookie_secure: bool) -> AuthState {
82 AuthState {
83 tokens: Some(tokens),
84 session_key,
85 cookie_secure,
86 }
87 }
88
89 pub fn is_enabled(&self) -> bool {
91 self.tokens.is_some()
92 }
93
94 pub(crate) fn tokens(&self) -> Option<&TokenSet> {
95 self.tokens.as_ref()
96 }
97
98 pub(crate) fn session_key(&self) -> &SessionKey {
99 &self.session_key
100 }
101
102 pub(crate) fn cookie_secure(&self) -> bool {
103 self.cookie_secure
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum Via {
110 Bearer,
112 Cookie,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Principal {
119 pub token_name: String,
121 pub scope: Scope,
123 pub via: Via,
125 pub expires_unix: Option<i64>,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum Access {
132 Open,
134 Requires(Scope),
136}
137
138pub fn required_access(method: &Method, route: &str) -> Access {
141 use Access::{Open, Requires};
142 use Scope::{Admin, Operate, Read};
143 match (method.as_str(), route) {
144 (_, "/healthz" | "/readyz" | "/" | "/assets/{*path}" | "/api/session"
148 | "/api/openapi.json") => Open,
149 ("POST", "/api/automations/{id}/trigger") => Open,
156 (
157 "GET" | "HEAD",
158 "/api/metrics"
159 | "/api/capabilities"
160 | "/api/fleet"
161 | "/api/fleet/stream"
163 | "/api/usage"
164 | "/api/workspaces"
165 | "/api/workspaces/{workspace}/agents"
166 | "/api/agents/{id}"
167 | "/api/agents/{id}/events"
168 | "/api/agents/{id}/stream"
169 | "/api/automations"
172 | "/api/automations/{id}/runs",
173 ) => Requires(Read),
174 (
175 "POST",
176 "/api/workspaces/{workspace}/agents"
177 | "/api/agents/{id}/kill"
178 | "/api/agents/{id}/respawn"
179 | "/api/agents/{id}/input"
180 | "/api/agents/{id}/end-input"
181 | "/api/automations/{id}/run",
184 )
185 | ("DELETE", "/api/agents/{id}") => Requires(Operate),
186 (_, "/mcp") => Requires(Operate),
190 ("POST", "/api/automations")
195 | ("DELETE", "/api/automations/{id}")
196 | ("PUT", "/api/automations/{id}/enabled") => Requires(Admin),
197 ("POST", "/api/workspaces")
198 | ("DELETE", "/api/workspaces/{name}")
199 | ("PUT", "/api/workspaces/{name}/config") => Requires(Admin),
200 _ => Requires(Admin),
201 }
202}
203
204pub const ON_BEHALF_OF_HEADER: &str = "x-prospero-on-behalf-of";
206
207pub(crate) fn subject_header(headers: &HeaderMap) -> Result<Option<String>, String> {
214 let Some(raw) = headers.get(ON_BEHALF_OF_HEADER) else {
215 return Ok(None);
216 };
217 let text = raw
218 .to_str()
219 .map_err(|_| "on-behalf-of must be valid UTF-8 text".to_string())?;
220 prospero_core::actor::validate_subject(text).map(|s| Some(s.to_string()))
221}
222
223pub(crate) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
225 headers
226 .get(header::AUTHORIZATION)?
227 .to_str()
228 .ok()?
229 .strip_prefix("Bearer ")
230 .map(str::trim)
231}
232
233pub(crate) fn resolve_principal(auth: &AuthState, headers: &HeaderMap) -> Option<Principal> {
236 let tokens = auth.tokens()?;
237 if let Some(token) = bearer_token(headers) {
238 return tokens.authenticate(token).map(|e| Principal {
239 token_name: e.name.clone(),
240 scope: e.scope,
241 via: Via::Bearer,
242 expires_unix: None,
243 });
244 }
245 let value = session::cookie_value(headers)?;
246 let now = chrono::Utc::now().timestamp();
247 session::verify(auth.session_key(), tokens, value, now).map(|(e, expires)| Principal {
248 token_name: e.name.clone(),
249 scope: e.scope,
250 via: Via::Cookie,
251 expires_unix: Some(expires),
252 })
253}
254
255pub async fn middleware(
257 State(auth): State<Arc<AuthState>>,
258 mut req: Request,
259 next: Next,
260) -> Response {
261 let subject = match subject_header(req.headers()) {
265 Ok(subject) => subject,
266 Err(why) => {
267 tracing::debug!(target: "prospero_auth", %why, "rejected: bad on-behalf-of header");
268 return ApiError::BadRequest(why).into_response();
269 }
270 };
271 if !auth.is_enabled() {
272 return prospero_core::actor::scope_with_subject(None, subject, next.run(req)).await;
273 }
274 let route = req
275 .extensions()
276 .get::<MatchedPath>()
277 .map(|m| m.as_str().to_owned())
278 .unwrap_or_default();
279 let need = match required_access(req.method(), &route) {
280 Access::Open => {
281 return prospero_core::actor::scope_with_subject(None, subject, next.run(req)).await;
282 }
283 Access::Requires(scope) => scope,
284 };
285 let Some(principal) = resolve_principal(&auth, req.headers()) else {
286 tracing::debug!(target: "prospero_auth", %route, "rejected: missing or invalid credential");
287 return ApiError::Unauthorized.into_response();
288 };
289 if principal.scope < need {
290 tracing::debug!(
291 target: "prospero_auth", %route, token = %principal.token_name,
292 have = principal.scope.as_str(), need = need.as_str(), "rejected: insufficient scope"
293 );
294 return ApiError::Forbidden(format!("requires scope {}", need.as_str())).into_response();
295 }
296 if principal.via == Via::Cookie
297 && !matches!(*req.method(), Method::GET | Method::HEAD)
298 && !session::same_origin(req.headers())
299 {
300 tracing::debug!(target: "prospero_auth", %route, token = %principal.token_name, "rejected: cross-origin cookie mutation");
301 return ApiError::Forbidden("cross-origin request".into()).into_response();
302 }
303 if !matches!(*req.method(), Method::GET | Method::HEAD) {
304 tracing::info!(
308 target: "prospero_audit", actor = %principal.token_name,
309 asserted_on_behalf_of = subject.as_deref().unwrap_or("-"),
310 method = %req.method(), %route, "api mutation"
311 );
312 }
313 let actor = principal.token_name.clone();
314 req.extensions_mut().insert(principal);
315 prospero_core::actor::scope_with_subject(Some(actor), subject, next.run(req)).await
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321
322 #[test]
323 fn unknown_routes_fail_closed_to_admin() {
324 assert_eq!(
325 required_access(&Method::GET, "/api/something-new"),
326 Access::Requires(Scope::Admin)
327 );
328 assert_eq!(required_access(&Method::GET, "/healthz"), Access::Open);
329 }
330
331 #[test]
337 fn automation_routes_sit_at_their_intended_scopes() {
338 use Access::{Open, Requires};
339 use Scope::{Admin, Operate, Read};
340
341 assert_eq!(
342 required_access(&Method::GET, "/api/automations"),
343 Requires(Read)
344 );
345 assert_eq!(
346 required_access(&Method::GET, "/api/automations/{id}/runs"),
347 Requires(Read)
348 );
349 assert_eq!(
350 required_access(&Method::POST, "/api/automations/{id}/run"),
351 Requires(Operate)
352 );
353 assert_eq!(
354 required_access(&Method::POST, "/api/automations"),
355 Requires(Admin)
356 );
357 assert_eq!(
358 required_access(&Method::DELETE, "/api/automations/{id}"),
359 Requires(Admin)
360 );
361 assert_eq!(
362 required_access(&Method::PUT, "/api/automations/{id}/enabled"),
363 Requires(Admin)
364 );
365 assert_eq!(
367 required_access(&Method::POST, "/api/automations/{id}/trigger"),
368 Open
369 );
370 assert_eq!(
372 required_access(&Method::GET, "/api/automations/{id}/trigger"),
373 Requires(Admin)
374 );
375 }
376
377 #[test]
378 fn session_key_minimum_and_redaction() {
379 assert!(SessionKey::from_bytes(vec![7; 31]).is_err());
380 let k = SessionKey::from_bytes(vec![7; 32]).unwrap();
381 assert_eq!(format!("{k:?}"), "SessionKey(<redacted>)");
382 }
383}