Skip to main content

solid_pod_rs_forge/
auth.rs

1//! Caller-identity resolution for forge requests.
2//!
3//! Three schemes, tried in order (cites JSS `forge` auth by name only):
4//!
5//! 1. **Forge push token** — `Authorization: Bearer f1.…` verified by
6//!    [`crate::token::verify`] (cheap HMAC, no signature per call).
7//! 2. **Pod session** — the embedding server's own `getAgent`/WAC layer
8//!    resolves a pod account and passes a [`ForgeAgent::Pod`] directly to
9//!    [`crate::ForgeService::handle`]; this module does not re-resolve it.
10//! 3. **NIP-98** — `Authorization: Nostr <base64>` verified by the core
11//!    [`solid_pod_rs::auth::nip98`] verifier (real Schnorr when the
12//!    `nip98-schnorr` feature is unified in, fail-closed otherwise).
13//!
14//! Any failure resolves to [`ForgeAgent::Anonymous`]; the service's
15//! guards then decide, fail-closed.
16
17use solid_pod_rs::auth::nip98;
18use solid_pod_rs::did_nostr_types::is_valid_hex_pubkey;
19
20use crate::ownership::ForgeAgent;
21use crate::request::ForgeRequest;
22
23/// Resolve a [`ForgeAgent`] from the request's `Authorization` header.
24/// `token_key` is the forge instance HMAC key; `now` is the current Unix
25/// time (injected for deterministic testing).
26#[must_use]
27pub fn resolve_agent(req: &ForgeRequest, token_key: &[u8], now: u64) -> ForgeAgent {
28    let Some(authz) = req.header("authorization") else {
29        return ForgeAgent::Anonymous;
30    };
31
32    // Scheme 1: forge push token.
33    if let Some(bearer) = authz.strip_prefix("Bearer ") {
34        let bearer = bearer.trim();
35        if bearer.starts_with("f1.") {
36            if let Ok(agent) = crate::token::verify(token_key, bearer, now) {
37                return agent;
38            }
39            return ForgeAgent::Anonymous;
40        }
41    }
42
43    // Scheme 3: NIP-98 (scheme 2, pod session, is injected by the server).
44    if authz.starts_with(nip98_prefix()) {
45        let url = req.absolute_url();
46        let body = if req.raw_body.is_empty() {
47            None
48        } else {
49            Some(req.raw_body.as_ref())
50        };
51        if let Ok(v) = nip98::verify_at(authz, &url, &req.method.to_uppercase(), body, now) {
52            if is_valid_hex_pubkey(&v.pubkey) {
53                return ForgeAgent::Nostr {
54                    pubkey_hex: v.pubkey,
55                };
56            }
57        }
58    }
59
60    ForgeAgent::Anonymous
61}
62
63fn nip98_prefix() -> &'static str {
64    "Nostr "
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use bytes::Bytes;
71
72    const KEY: &[u8] = b"forge-instance-hmac-key-0123456789";
73
74    fn req_with_auth(authz: Option<&str>) -> ForgeRequest {
75        ForgeRequest {
76            method: "GET".into(),
77            path: "/forge/x".into(),
78            query: String::new(),
79            headers: authz
80                .map(|a| vec![("authorization".to_string(), a.to_string())])
81                .unwrap_or_default(),
82            raw_body: Bytes::new(),
83            host_url: Some("https://pod.example".into()),
84        }
85    }
86
87    #[test]
88    fn no_header_is_anonymous() {
89        assert_eq!(
90            resolve_agent(&req_with_auth(None), KEY, 0),
91            ForgeAgent::Anonymous
92        );
93    }
94
95    #[test]
96    fn valid_forge_token_resolves() {
97        let agent = ForgeAgent::Nostr {
98            pubkey_hex: "c".repeat(64),
99        };
100        let tok = crate::token::mint(KEY, &agent, 1000, 3600).unwrap();
101        let req = req_with_auth(Some(&format!("Bearer {tok}")));
102        assert_eq!(resolve_agent(&req, KEY, 1500), agent);
103    }
104
105    #[test]
106    fn expired_or_tampered_token_is_anonymous() {
107        let agent = ForgeAgent::Nostr {
108            pubkey_hex: "c".repeat(64),
109        };
110        let tok = crate::token::mint(KEY, &agent, 1000, 10).unwrap();
111        let req = req_with_auth(Some(&format!("Bearer {tok}")));
112        // Expired.
113        assert_eq!(resolve_agent(&req, KEY, 9999), ForgeAgent::Anonymous);
114        // Wrong key.
115        assert_eq!(
116            resolve_agent(&req, b"different-key-abcdefghijklmn", 1500),
117            ForgeAgent::Anonymous
118        );
119    }
120
121    #[test]
122    fn non_forge_bearer_is_anonymous() {
123        let req = req_with_auth(Some("Bearer some-oauth-jwt"));
124        assert_eq!(resolve_agent(&req, KEY, 0), ForgeAgent::Anonymous);
125    }
126
127    #[test]
128    fn malformed_nip98_is_anonymous() {
129        // Without the nip98-schnorr feature unified in, verification fails
130        // closed; with a garbage token it is Anonymous regardless.
131        let req = req_with_auth(Some("Nostr bm90LWEtdmFsaWQ="));
132        assert_eq!(resolve_agent(&req, KEY, 0), ForgeAgent::Anonymous);
133    }
134}