Skip to main content

forest/rpc/
auth_layer.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use crate::auth::{JWT_IDENTIFIER, verify_token};
5use crate::key_management::KeyStore;
6use crate::prelude::*;
7use crate::rpc::error::implementation_defined_errors::INSUFFICIENT_PERMISSIONS;
8use crate::rpc::{CANCEL_METHOD_NAME, Permission, RpcMethod as _, chain};
9use ahash::HashMap;
10use futures::future::Either;
11use http::header::HeaderValue;
12use jsonrpsee::MethodResponse;
13use jsonrpsee::core::middleware::{Batch, BatchEntry, BatchEntryErr, Notification};
14use jsonrpsee::server::middleware::rpc::RpcServiceT;
15use jsonrpsee::types::Id;
16use jsonrpsee::types::{ErrorObject, ErrorObjectOwned, error::ErrorCode};
17use parking_lot::RwLock;
18use std::sync::LazyLock;
19use tower::Layer;
20use tracing::debug;
21
22static METHOD_NAME2REQUIRED_PERMISSION: LazyLock<HashMap<&str, Permission>> = LazyLock::new(|| {
23    let mut access = HashMap::new();
24
25    macro_rules! insert {
26        ($ty:ty) => {
27            access.insert(<$ty>::NAME, <$ty>::PERMISSION);
28
29            if let Some(alias) = <$ty>::NAME_ALIAS {
30                access.insert(alias, <$ty>::PERMISSION);
31            }
32        };
33    }
34    super::for_each_rpc_method!(insert);
35
36    access.insert(chain::CHAIN_NOTIFY, Permission::Read);
37    access.insert(CANCEL_METHOD_NAME, Permission::Read);
38
39    access
40});
41
42/// The lowercase wire string for a permission, as it appears in a JWT `Allow`
43/// claim list and in Lotus's permission errors.
44fn permission_str(permission: Permission) -> &'static str {
45    match permission {
46        Permission::Admin => "admin",
47        Permission::Sign => "sign",
48        Permission::Write => "write",
49        Permission::Read => "read",
50    }
51}
52
53fn is_allowed(required_by_method: Permission, claimed_by_user: &[String]) -> bool {
54    let needle = permission_str(required_by_method);
55    claimed_by_user.iter().any(|haystack| haystack == needle)
56}
57
58#[derive(Clone)]
59pub struct AuthLayer {
60    /// Permission claims resolved once for this connection (via [`resolve_claims`]
61    /// at the HTTP request / WebSocket upgrade). Token-verification failures are
62    /// rejected with an HTTP `401` before this layer is built, so the claims are
63    /// always present here.
64    claims: Arc<[String]>,
65}
66
67impl AuthLayer {
68    pub fn new(claims: Arc<[String]>) -> Self {
69        Self { claims }
70    }
71}
72
73impl<S> Layer<S> for AuthLayer {
74    type Service = Auth<S>;
75
76    fn layer(&self, service: S) -> Self::Service {
77        Auth {
78            claims: self.claims.clone(),
79            service,
80        }
81    }
82}
83
84#[derive(Clone)]
85pub struct Auth<S> {
86    claims: Arc<[String]>,
87    service: S,
88}
89
90impl<S> Auth<S> {
91    /// Authorize a single method call against this connection's permission claims.
92    ///
93    /// Returns a JSON-RPC error for an unknown method ([`ErrorCode::MethodNotFound`])
94    /// or one the claims don't permit ([`INSUFFICIENT_PERMISSIONS`]). Token-level
95    /// auth failures never reach here — they are rejected with an HTTP `401` at the
96    /// transport layer before any JSON-RPC dispatch.
97    fn authorize(&self, method_name: &str) -> Result<(), ErrorObjectOwned> {
98        match METHOD_NAME2REQUIRED_PERMISSION.get(&method_name) {
99            None => Err(ErrorObject::from(ErrorCode::MethodNotFound)),
100            Some(&required) if is_allowed(required, &self.claims) => Ok(()),
101            Some(&required) => {
102                tracing::warn!("insufficient permissions to invoke method {method_name}");
103                Err(insufficient_permissions(method_name, required))
104            }
105        }
106    }
107}
108
109impl<S> RpcServiceT for Auth<S>
110where
111    S: RpcServiceT<
112            MethodResponse = MethodResponse,
113            NotificationResponse = MethodResponse,
114            BatchResponse = MethodResponse,
115        > + Send
116        + Sync
117        + Clone
118        + 'static,
119{
120    type MethodResponse = S::MethodResponse;
121    type NotificationResponse = S::NotificationResponse;
122    type BatchResponse = S::BatchResponse;
123
124    fn call<'a>(
125        &self,
126        req: jsonrpsee::types::Request<'a>,
127    ) -> impl Future<Output = Self::MethodResponse> + Send + 'a {
128        match self.authorize(req.method_name()) {
129            Ok(()) => Either::Left(self.service.call(req)),
130            Err(e) => Either::Right(async move { MethodResponse::error(req.id(), e) }),
131        }
132    }
133
134    fn notification<'a>(
135        &self,
136        n: Notification<'a>,
137    ) -> impl Future<Output = Self::NotificationResponse> + Send + 'a {
138        match self.authorize(n.method_name()) {
139            Ok(()) => Either::Left(self.service.notification(n)),
140            Err(e) => Either::Right(async move { MethodResponse::error(Id::Null, e) }),
141        }
142    }
143
144    fn batch<'a>(&self, batch: Batch<'a>) -> impl Future<Output = Self::BatchResponse> + Send + 'a {
145        let entries = batch
146            .into_iter()
147            .filter_map(|entry| match entry {
148                Ok(BatchEntry::Call(req)) => Some(match self.authorize(req.method_name()) {
149                    Ok(()) => Ok(BatchEntry::Call(req)),
150                    Err(e) => Err(BatchEntryErr::new(req.id(), e)),
151                }),
152                Ok(BatchEntry::Notification(n)) => match self.authorize(n.method_name()) {
153                    Ok(_) => Some(Ok(BatchEntry::Notification(n))),
154                    Err(_) => None,
155                },
156                Err(err) => Some(Err(err)),
157            })
158            .collect_vec();
159        self.service.batch(Batch::from(entries))
160    }
161}
162
163/// Build the JSON-RPC error returned when an authenticated caller lacks the
164/// permission a method requires. The message mirrors Lotus
165/// (`missing permission to invoke '<method>' (need '<perm>')`), but the code is
166/// Forest's implementation-defined [`INSUFFICIENT_PERMISSIONS`] rather than an
167/// HTTP status masquerading as a JSON-RPC error code.
168fn insufficient_permissions(method: &str, required: Permission) -> ErrorObjectOwned {
169    ErrorObject::owned(
170        INSUFFICIENT_PERMISSIONS,
171        format!(
172            "missing permission to invoke '{method}' (need '{}')",
173            permission_str(required)
174        ),
175        None::<()>,
176    )
177}
178
179/// Verify JWT Token and return the token's permissions.
180fn auth_verify(token: &str, keystore: &RwLock<KeyStore>) -> anyhow::Result<Vec<String>> {
181    let key_info = keystore.read().get(JWT_IDENTIFIER)?;
182    Ok(verify_token(token, key_info.private_key())?)
183}
184
185/// Resolve the connection's `Authorization` header into permission claims.
186///
187/// This performs the (relatively expensive) JWT verification and is intended to
188/// be called once per connection (at the HTTP request / WebSocket upgrade), not
189/// once per request. Returns `Err(reason)` when the header is malformed or the
190/// token fails verification; the caller rejects such connections with a bare
191/// HTTP `401 Unauthorized` before any JSON-RPC dispatch, matching Lotus. The
192/// `reason` is for server-side logging only and is not sent to the client.
193pub(super) fn resolve_claims(
194    keystore: &RwLock<KeyStore>,
195    auth_header: Option<&HeaderValue>,
196) -> Result<Arc<[String]>, &'static str> {
197    let claims: Vec<String> = match auth_header {
198        Some(header) => {
199            let token = header
200                .to_str()
201                .map_err(|_| "malformed authorization header")?
202                .strip_prefix("Bearer ")
203                .ok_or("malformed authorization header")?;
204
205            auth_verify(token, keystore).map_err(|_| "invalid authorization token")?
206        }
207        // If no token is passed, assume read behavior.
208        None => vec!["read".to_owned()],
209    };
210    debug!("Decoded JWT permissions: {}", claims.join(","));
211    Ok(claims.into())
212}
213
214#[cfg(test)]
215mod tests {
216    use self::chain::ChainHead;
217    use super::*;
218    use crate::rpc::wallet;
219    use chrono::Duration;
220
221    fn empty_keystore() -> Arc<RwLock<KeyStore>> {
222        Arc::new(RwLock::new(
223            KeyStore::new(crate::KeyStoreConfig::Memory).unwrap(),
224        ))
225    }
226
227    /// Build a keystore with a JWT signing key and a matching `Bearer` token
228    /// granting `perms`.
229    fn keystore_with_token(perms: &[&str]) -> (Arc<RwLock<KeyStore>>, String) {
230        use crate::auth::*;
231        let keystore = empty_keystore();
232        let key_info = generate_priv_key();
233        keystore
234            .write()
235            .put(JWT_IDENTIFIER, key_info.clone())
236            .unwrap();
237        let token = create_token(
238            perms.iter().map(ToString::to_string).collect(),
239            key_info.private_key(),
240            Duration::hours(1),
241        )
242        .unwrap();
243        (keystore, token)
244    }
245
246    fn auth_with(claims: &[&str]) -> Auth<()> {
247        Auth {
248            claims: claims.iter().map(ToString::to_string).collect(),
249            service: (),
250        }
251    }
252
253    // --- resolve_claims: token-level outcomes (HTTP 401 is derived from `Err`) ---
254
255    #[test]
256    fn resolve_claims_no_header_defaults_to_read() {
257        let claims = resolve_claims(&empty_keystore(), None).unwrap();
258        assert_eq!(&*claims, &["read".to_owned()]);
259    }
260
261    #[test]
262    fn resolve_claims_rejects_malformed_header() {
263        let keystore = empty_keystore();
264
265        // Not valid UTF-8, so it cannot be a JWT.
266        let header = HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap();
267        assert_eq!(
268            resolve_claims(&keystore, Some(&header)).unwrap_err(),
269            "malformed authorization header"
270        );
271
272        // No `Bearer ` scheme prefix.
273        let header = HeaderValue::from_static("Cthulhu");
274        assert_eq!(
275            resolve_claims(&keystore, Some(&header)).unwrap_err(),
276            "malformed authorization header"
277        );
278    }
279
280    #[test]
281    fn resolve_claims_rejects_invalid_token() {
282        let header = HeaderValue::from_static("Bearer Azathoth");
283        assert_eq!(
284            resolve_claims(&empty_keystore(), Some(&header)).unwrap_err(),
285            "invalid authorization token"
286        );
287    }
288
289    #[test]
290    fn resolve_claims_accepts_valid_token() {
291        let (keystore, token) = keystore_with_token(crate::auth::ADMIN);
292
293        let header = HeaderValue::from_str(&format!("Bearer {token}")).unwrap();
294        let claims = resolve_claims(&keystore, Some(&header)).unwrap();
295        assert!(claims.iter().any(|c| c == "admin"));
296
297        // A bare token without the `Bearer ` scheme is malformed, even though the
298        // value itself is a valid token.
299        let header = HeaderValue::from_str(&token).unwrap();
300        assert_eq!(
301            resolve_claims(&keystore, Some(&header)).unwrap_err(),
302            "malformed authorization header"
303        );
304
305        // Only a single `Bearer ` prefix is stripped: a doubled prefix leaves a
306        // `Bearer ...` value that is not a valid token.
307        let header = HeaderValue::from_str(&format!("Bearer Bearer {token}")).unwrap();
308        assert_eq!(
309            resolve_claims(&keystore, Some(&header)).unwrap_err(),
310            "invalid authorization token"
311        );
312    }
313
314    // --- authorize: per-method permission checks against resolved claims ---
315
316    #[test]
317    fn authorize_allows_methods_within_permissions() {
318        assert!(auth_with(&["read"]).authorize(ChainHead::NAME).is_ok());
319    }
320
321    #[test]
322    fn authorize_denies_insufficient_permissions_with_jsonrpc_error() {
323        let err = auth_with(&["read"])
324            .authorize(wallet::WalletNew::NAME)
325            .unwrap_err();
326        // A JSON-RPC application error, NOT an HTTP status smuggled into the code.
327        assert_eq!(err.code(), INSUFFICIENT_PERMISSIONS);
328        assert_eq!(
329            err.message(),
330            format!(
331                "missing permission to invoke '{}' (need 'write')",
332                wallet::WalletNew::NAME
333            )
334        );
335    }
336
337    #[test]
338    fn authorize_unknown_method_is_method_not_found() {
339        let err = auth_with(&["read"])
340            .authorize("Cthulhu.InvokeElderGods")
341            .unwrap_err();
342        assert_eq!(err.code(), ErrorCode::MethodNotFound.code());
343    }
344
345    /// Resolved admin claims, threaded through `AuthLayer::layer`, authorize a
346    /// write method without re-touching the token.
347    #[test]
348    fn layer_propagates_resolved_claims() {
349        let (keystore, token) = keystore_with_token(crate::auth::ADMIN);
350        let header = HeaderValue::from_str(&format!("Bearer {token}")).unwrap();
351        let claims = resolve_claims(&keystore, Some(&header)).unwrap();
352
353        let auth = AuthLayer::new(claims).layer(());
354        assert!(auth.authorize(wallet::WalletNew::NAME).is_ok());
355    }
356}