Skip to main content

fslite_server/
auth.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2
3use async_trait::async_trait;
4use axum::extract::{FromRef, FromRequestParts, Path};
5use axum::http::HeaderMap;
6use axum::http::request::Parts;
7use fslite_core::{Capability, RequestContext, WorkspaceId};
8use serde_json::Value;
9
10use crate::error::ApiError;
11use crate::state::AppState;
12
13/// The workspace and capabilities a credential resolves to.
14#[derive(Clone, Debug)]
15pub struct AuthenticatedActor {
16    /// The single workspace this credential is scoped to.
17    pub workspace_id: WorkspaceId,
18    /// The capabilities granted within that workspace.
19    pub capabilities: BTreeSet<Capability>,
20    /// Safe actor fields copied verbatim into `RequestContext::actor_metadata`.
21    pub actor_metadata: BTreeMap<String, Value>,
22}
23
24/// Resolves inbound request headers to an authenticated actor.
25///
26/// Implementations may look up bearer tokens, verify JWTs, call an external
27/// identity service, etc. `fslite-server` ships one reference implementation,
28/// [`BearerTokenAuthProvider`]; production deployments are expected to
29/// provide their own.
30#[async_trait]
31pub trait AuthProvider: Send + Sync {
32    /// Authenticates a request from its headers alone.
33    async fn authenticate(&self, headers: &HeaderMap) -> Result<AuthenticatedActor, ApiError>;
34}
35
36/// A static bearer-token credential store: `Authorization: Bearer <token>`.
37pub struct BearerTokenAuthProvider {
38    tokens: HashMap<String, AuthenticatedActor>,
39}
40
41impl BearerTokenAuthProvider {
42    /// Builds a provider from a fixed token → actor map.
43    pub fn new(tokens: HashMap<String, AuthenticatedActor>) -> Self {
44        Self { tokens }
45    }
46}
47
48#[async_trait]
49impl AuthProvider for BearerTokenAuthProvider {
50    async fn authenticate(&self, headers: &HeaderMap) -> Result<AuthenticatedActor, ApiError> {
51        let header = headers
52            .get(axum::http::header::AUTHORIZATION)
53            .and_then(|value| value.to_str().ok())
54            .ok_or_else(|| ApiError::Unauthenticated("missing authorization header".into()))?;
55
56        let token = header
57            .strip_prefix("Bearer ")
58            .ok_or_else(|| ApiError::Unauthenticated("expected a Bearer token".into()))?;
59
60        self.tokens
61            .get(token)
62            .cloned()
63            .ok_or_else(|| ApiError::Unauthenticated("unrecognized token".into()))
64    }
65}
66
67/// An extractor that authenticates the request and enforces that the
68/// authenticated actor's workspace matches the `{workspace_id}` path segment.
69pub struct Ctx(pub RequestContext);
70
71impl<S> FromRequestParts<S> for Ctx
72where
73    AppState: FromRef<S>,
74    S: Send + Sync,
75{
76    type Rejection = ApiError;
77
78    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
79        let app_state = AppState::from_ref(state);
80        let actor = app_state.auth.authenticate(&parts.headers).await?;
81
82        // `Path<WorkspaceId>` only deserializes cleanly when the matched
83        // route has exactly one captured segment. Routes nested under
84        // `/fs/{*path}` also capture the wildcard tail, so we read the raw
85        // param map instead and parse the `workspace_id` entry by name —
86        // this works regardless of how many other segments the route captures.
87        let Path(raw_params) = Path::<HashMap<String, String>>::from_request_parts(parts, state)
88            .await
89            .map_err(|_| ApiError::MalformedBody("invalid path parameters".into()))?;
90        let raw_workspace_id = raw_params
91            .get("workspace_id")
92            .ok_or_else(|| ApiError::MalformedBody("missing workspace id in path".into()))?;
93        let workspace_id = WorkspaceId::parse(raw_workspace_id)
94            .map_err(|_| ApiError::MalformedBody("invalid workspace id in path".into()))?;
95
96        if actor.workspace_id != workspace_id {
97            return Err(ApiError::WorkspaceMismatch);
98        }
99
100        Ok(Ctx(RequestContext::new(
101            workspace_id,
102            actor.actor_metadata,
103            actor.capabilities,
104        )))
105    }
106}