Skip to main content

aauth_protocol/client/
person_server.rs

1//! The agent's calls to its Person Server (AAuth §Person Server), each
2//! signed as the agent with its agent token.
3
4use http::{HeaderMap, Method, StatusCode};
5use serde_json::{json, Value};
6
7use super::connect::ConnectTo;
8use super::guard::admit_host;
9use crate::discovery::{read_limited, MAX_DOCUMENT_BYTES};
10use crate::error::{Error, Result};
11use crate::httpsig::{header_map, sign_jwt, Outgoing};
12use crate::identifier::same_origin;
13use crate::keys::SigningKey;
14use crate::metadata::{dwk, metadata_url, PersonServerMetadata};
15
16/// What a Person Server answered.
17#[derive(Debug)]
18#[non_exhaustive]
19pub enum Answer {
20    /// `200`: what was asked for, such as `{"auth_token": …}`.
21    Granted(Value),
22    /// `202`: a human is deciding. Poll `location` after `retry_after` seconds.
23    Pending {
24        /// The pending URL, absolute, on the Person Server's own origin.
25        location: String,
26        /// Seconds to wait before polling.
27        retry_after: u64,
28    },
29    /// A refusal: a `4xx`, or a `200` saying `"permission": "denied"`.
30    Denied {
31        /// The HTTP status.
32        status: u16,
33        /// Why, from whichever field the Person Server used.
34        reason: String,
35    },
36}
37
38/// Who the agent acts for, as the Person Server knows them: a `login_hint`,
39/// and the `tenant` a multi-tenant Person Server is asked under.
40#[derive(Clone, Copy, Debug, Default)]
41pub struct Person<'a> {
42    /// The hint the Person Server's directory recognises, such as `user:sam@acme.example`.
43    pub login_hint: Option<&'a str>,
44    /// The person's organisation, at a multi-tenant Person Server.
45    pub tenant: Option<&'a str>,
46}
47
48impl Person<'_> {
49    /// Set a request body's `login_hint` and `tenant` to these, removing any
50    /// it already carried. A proxy signing for code it does not trust uses
51    /// this so that code cannot name someone else.
52    pub fn name_in(&self, body: &mut Value) {
53        let Some(fields) = body.as_object_mut() else {
54            return;
55        };
56        fields.remove("login_hint");
57        fields.remove("tenant");
58        if let Some(hint) = self.login_hint {
59            fields.insert("login_hint".into(), json!(hint));
60        }
61        if let Some(tenant) = self.tenant {
62            fields.insert("tenant".into(), json!(tenant));
63        }
64    }
65}
66
67/// The agent as it signs: its agent token, and the key that token binds.
68#[derive(Clone, Copy, Debug)]
69pub struct Agent<'a> {
70    /// The agent token its provider issued.
71    pub agent_token: &'a str,
72    /// The key the token's `cnf.jwk` names.
73    pub key: &'a SigningKey,
74}
75
76/// Where a [`PersonServerClient`] may send requests. The default is
77/// production's: `https` only, and never an IP literal or `localhost`.
78#[derive(Clone, Debug, Default)]
79pub struct Reach {
80    /// Allow `http://` URLs. Local development only.
81    pub allow_http: bool,
82    /// Allow IP literals and `localhost`. Local development only; with a
83    /// guarded client, its resolver still refuses private answers.
84    pub allow_private: bool,
85    /// Send requests for some authorities to other addresses.
86    pub connect_to: ConnectTo,
87}
88
89/// The agent's side of a Person Server conversation.
90///
91/// Every call is signed as the agent. A `5xx`, a `429` or a transport
92/// failure is an [`Error::Http`], never an [`Answer::Denied`], so a dropped
93/// connection is not mistaken for a refusal.
94#[derive(Clone, Debug)]
95pub struct PersonServerClient {
96    http: reqwest::Client,
97    reach: Reach,
98}
99
100impl PersonServerClient {
101    /// A client over `http`. Use [`super::guarded_client`]'s when Person
102    /// Server URLs come from someone else.
103    pub fn new(http: reqwest::Client, reach: Reach) -> Self {
104        Self { http, reach }
105    }
106
107    fn request(&self, method: Method, url: &str) -> Result<reqwest::RequestBuilder> {
108        let route = self.reach.connect_to.route(url)?;
109        let request = self.http.request(method, &route.url);
110        Ok(match route.host {
111            Some(host) => request.header("host", host),
112            None => request,
113        })
114    }
115
116    fn admit(&self, url: &str) -> Result<()> {
117        if !(url.starts_with("https://") || (self.reach.allow_http && url.starts_with("http://"))) {
118            return Err(Error::Url(format!("refusing to call {url}")));
119        }
120        if self.reach.allow_private {
121            return Ok(());
122        }
123        admit_host(url)
124    }
125
126    /// The Person Server's `aauth-person.json`. It must name `issuer`, and
127    /// every endpoint in it must be on the issuer's own origin.
128    pub async fn metadata(&self, issuer: &str) -> Result<PersonServerMetadata> {
129        let url = metadata_url(issuer, dwk::PERSON);
130        self.admit(&url)?;
131        let response = self
132            .request(Method::GET, &url)?
133            .send()
134            .await
135            .map_err(|e| Error::Http(format!("{url}: {e}")))?;
136        if !response.status().is_success() {
137            return Err(Error::Discovery(format!(
138                "{url} answered {}",
139                response.status()
140            )));
141        }
142        let body = read_limited(response, MAX_DOCUMENT_BYTES).await?;
143        let metadata: PersonServerMetadata = serde_json::from_slice(&body)
144            .map_err(|e| Error::Discovery(format!("{url} is unreadable: {e}")))?;
145        if metadata.issuer.trim_end_matches('/') != issuer.trim_end_matches('/') {
146            return Err(Error::Discovery(format!("{url} names another issuer")));
147        }
148        // A caller that routes only the Person Server's own host would
149        // otherwise be sent somewhere nobody declared.
150        let endpoints = [
151            Some(&metadata.auth_token_endpoint),
152            metadata.person_token_endpoint.as_ref(),
153            metadata.mission_endpoint.as_ref(),
154        ];
155        if let Some(elsewhere) = endpoints
156            .into_iter()
157            .flatten()
158            .find(|endpoint| !same_origin(issuer, endpoint))
159        {
160            return Err(Error::Discovery(format!(
161                "{url} names an endpoint on another host: {elsewhere}"
162            )));
163        }
164        Ok(metadata)
165    }
166
167    /// A signed `POST` of JSON `body`, or a `GET` or `DELETE` with none, to `url`.
168    pub async fn call(
169        &self,
170        agent: &Agent<'_>,
171        method: Method,
172        url: &str,
173        body: Option<&Value>,
174    ) -> Result<Answer> {
175        self.admit(url)?;
176        let bytes = body
177            .map(serde_json::to_vec)
178            .transpose()
179            .map_err(|e| Error::Invalid(e.to_string()))?;
180        let headers = header_map(&sign_jwt(
181            agent.key,
182            agent.agent_token,
183            &Outgoing {
184                method: method.as_str(),
185                url,
186                content_type: None,
187                body: bytes.as_deref(),
188                created: crate::now_unix(),
189            },
190        )?)?;
191        let mut request = self.request(method, url)?.headers(headers);
192        if let Some(bytes) = bytes {
193            request = request.body(bytes);
194        }
195        let response = request
196            .send()
197            .await
198            .map_err(|e| Error::Http(format!("{url}: {e}")))?;
199        let status = response.status();
200        let headers = response.headers().clone();
201        let body = read_limited(response, MAX_DOCUMENT_BYTES).await?;
202        answer_from(url, status, &headers, &body)
203    }
204
205    /// Ask for a person token for `resource`, under a mission when `mission_s256` names one.
206    pub async fn person_token(
207        &self,
208        agent: &Agent<'_>,
209        metadata: &PersonServerMetadata,
210        resource: &str,
211        person: &Person<'_>,
212        mission_s256: Option<&str>,
213    ) -> Result<Answer> {
214        let endpoint = metadata
215            .person_token_endpoint
216            .as_deref()
217            .ok_or_else(|| Error::Discovery("this Person Server issues no person tokens".into()))?;
218        let mut body = json!({ "resource": resource });
219        person.name_in(&mut body);
220        if let Some(s256) = mission_s256 {
221            body["mission_s256"] = json!(s256);
222        }
223        self.call(agent, Method::POST, endpoint, Some(&body)).await
224    }
225
226    /// Exchange a resource token for an auth token, presenting the token the
227    /// resource token was issued against.
228    pub async fn auth_token(
229        &self,
230        agent: &Agent<'_>,
231        metadata: &PersonServerMetadata,
232        resource_token: &str,
233        presented_token: &str,
234        person: &Person<'_>,
235    ) -> Result<Answer> {
236        let mut body = json!({
237            "resource_token": resource_token,
238            "presented_token": presented_token,
239            "capabilities": ["interaction"],
240        });
241        person.name_in(&mut body);
242        self.call(
243            agent,
244            Method::POST,
245            &metadata.auth_token_endpoint,
246            Some(&body),
247        )
248        .await
249    }
250
251    /// Propose a mission: `{"description": …, "resources": [...]}`.
252    pub async fn propose_mission(
253        &self,
254        agent: &Agent<'_>,
255        metadata: &PersonServerMetadata,
256        proposal: &Value,
257        person: &Person<'_>,
258    ) -> Result<Answer> {
259        let endpoint = self.mission_endpoint(metadata)?;
260        let mut body = proposal.clone();
261        person.name_in(&mut body);
262        self.call(agent, Method::POST, endpoint, Some(&body)).await
263    }
264
265    /// Act on a mission: `{"action": "update", "description": …}`, or
266    /// `{"action": "completion", "summary": …}`.
267    pub async fn mission_action(
268        &self,
269        agent: &Agent<'_>,
270        metadata: &PersonServerMetadata,
271        s256: &str,
272        action: &Value,
273    ) -> Result<Answer> {
274        let url = format!(
275            "{}/{s256}",
276            self.mission_endpoint(metadata)?.trim_end_matches('/')
277        );
278        self.call(agent, Method::POST, &url, Some(action)).await
279    }
280
281    /// Poll a pending request once.
282    pub async fn poll(&self, agent: &Agent<'_>, location: &str) -> Result<Answer> {
283        self.call(agent, Method::GET, location, None).await
284    }
285
286    /// Withdraw a pending request.
287    pub async fn cancel(&self, agent: &Agent<'_>, location: &str) -> Result<Answer> {
288        self.call(agent, Method::DELETE, location, None).await
289    }
290
291    fn mission_endpoint<'m>(&self, metadata: &'m PersonServerMetadata) -> Result<&'m str> {
292        metadata
293            .mission_endpoint
294            .as_deref()
295            .ok_or_else(|| Error::Discovery("this Person Server has no missions".into()))
296    }
297}
298
299/// Read a Person Server's response to a request sent to `url`.
300pub fn answer_from(
301    url: &str,
302    status: StatusCode,
303    headers: &HeaderMap,
304    body: &[u8],
305) -> Result<Answer> {
306    if status == StatusCode::ACCEPTED {
307        let location = headers
308            .get("location")
309            .and_then(|v| v.to_str().ok())
310            .ok_or_else(|| Error::Http("a pending answer has no Location".into()))?;
311        let location = url::Url::parse(url)
312            .and_then(|base| base.join(location))
313            .map_err(|e| Error::Http(format!("bad Location: {e}")))?
314            .to_string();
315        if !same_origin(url, &location) {
316            return Err(Error::Http(format!(
317                "pending Location on another host: {location}"
318            )));
319        }
320        let retry_after = headers
321            .get("retry-after")
322            .and_then(|v| v.to_str().ok())
323            .and_then(|v| v.parse().ok())
324            .unwrap_or(5);
325        return Ok(Answer::Pending {
326            location,
327            retry_after,
328        });
329    }
330    // A 5xx or 429 is the Person Server (or a proxy in front of it) failing,
331    // not a decision: answering Denied would turn a dropped tunnel into a refusal.
332    if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
333        return Err(Error::Http(format!("{url} answered {status}")));
334    }
335    let body: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
336    if status.is_success() {
337        if body.get("permission").and_then(Value::as_str) == Some("denied") {
338            return Ok(Answer::Denied {
339                status: 403,
340                reason: reason_of(&body),
341            });
342        }
343        return Ok(Answer::Granted(body));
344    }
345    Ok(Answer::Denied {
346        status: status.as_u16(),
347        reason: reason_of(&body),
348    })
349}
350
351fn reason_of(body: &Value) -> String {
352    ["reason", "detail", "error_description", "error"]
353        .iter()
354        .find_map(|k| body.get(*k).and_then(Value::as_str))
355        .unwrap_or("refused")
356        .to_string()
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use http::HeaderValue;
363
364    #[test]
365    fn reasons_come_from_whichever_field_the_server_used() {
366        assert_eq!(reason_of(&json!({"reason": "over cap"})), "over cap");
367        assert_eq!(reason_of(&json!({"error": "denied"})), "denied");
368        assert_eq!(
369            reason_of(&json!({"error": "denied", "detail": "Dana declined"})),
370            "Dana declined"
371        );
372        assert_eq!(reason_of(&Value::Null), "refused");
373    }
374
375    #[test]
376    fn a_failing_person_server_is_not_a_refusal() {
377        for status in [500, 502, 503, 530, 429] {
378            let status = StatusCode::from_u16(status).unwrap();
379            let answer = answer_from(
380                "https://ps.example/pending/1",
381                status,
382                &HeaderMap::new(),
383                b"<html>",
384            );
385            assert!(answer.is_err(), "{status} read as {answer:?}");
386        }
387    }
388
389    #[test]
390    fn a_client_error_is_still_a_refusal() {
391        let answer = answer_from(
392            "https://ps.example/pending/1",
393            StatusCode::FORBIDDEN,
394            &HeaderMap::new(),
395            br#"{"reason":"over cap"}"#,
396        );
397        assert!(
398            matches!(answer, Ok(Answer::Denied { status: 403, ref reason }) if reason == "over cap")
399        );
400    }
401
402    #[test]
403    fn a_pending_location_on_another_host_is_refused() {
404        let mut headers = HeaderMap::new();
405        headers.insert(
406            "location",
407            HeaderValue::from_static("https://169.254.169.254/pending/1"),
408        );
409        let refused = answer_from(
410            "https://ps.example/token",
411            StatusCode::ACCEPTED,
412            &headers,
413            b"",
414        )
415        .unwrap_err();
416        assert!(refused.to_string().contains("another host"), "{refused}");
417        headers.insert("location", HeaderValue::from_static("/pending/1"));
418        let Ok(Answer::Pending {
419            location,
420            retry_after,
421        }) = answer_from(
422            "https://ps.example/token",
423            StatusCode::ACCEPTED,
424            &headers,
425            b"",
426        )
427        else {
428            panic!()
429        };
430        assert_eq!(location, "https://ps.example/pending/1");
431        assert_eq!(retry_after, 5);
432    }
433
434    #[test]
435    fn plain_http_is_refused_unless_allowed() {
436        let client = PersonServerClient::new(reqwest::Client::new(), Reach::default());
437        assert!(client.admit("http://ps.example").is_err());
438        assert!(client.admit("https://ps.example").is_ok());
439        let dev = PersonServerClient::new(
440            reqwest::Client::new(),
441            Reach {
442                allow_http: true,
443                allow_private: true,
444                ..Reach::default()
445            },
446        );
447        assert!(dev.admit("http://127.0.0.1:3400").is_ok());
448    }
449
450    #[test]
451    fn a_private_or_literal_host_is_refused_in_production() {
452        let client = PersonServerClient::new(reqwest::Client::new(), Reach::default());
453        assert!(client.admit("https://127.0.0.1/token").is_err());
454        assert!(client.admit("https://169.254.169.254/").is_err());
455        assert!(client.admit("https://ps.localhost/").is_err());
456    }
457
458    #[test]
459    fn the_session_names_the_person_never_the_body() {
460        let mut body = json!({ "login_hint": "dana", "tenant": "other", "description": "x" });
461        Person {
462            login_hint: Some("sam"),
463            tenant: None,
464        }
465        .name_in(&mut body);
466        assert_eq!(body, json!({ "login_hint": "sam", "description": "x" }));
467    }
468
469    #[tokio::test]
470    async fn a_document_naming_another_host_is_refused() {
471        use tokio::io::{AsyncReadExt, AsyncWriteExt};
472        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
473        let issuer = format!("http://{}", listener.local_addr().unwrap());
474        let body = json!({
475            "issuer": issuer,
476            "auth_token_endpoint": "http://metadata.google.internal/token",
477        })
478        .to_string();
479        tokio::spawn(async move {
480            let (mut socket, _) = listener.accept().await.unwrap();
481            let mut buf = [0u8; 4096];
482            let _ = socket.read(&mut buf).await;
483            let response = format!(
484                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}",
485                body.len()
486            );
487            socket.write_all(response.as_bytes()).await.unwrap();
488        });
489        let dev = PersonServerClient::new(
490            reqwest::Client::new(),
491            Reach {
492                allow_http: true,
493                allow_private: true,
494                ..Reach::default()
495            },
496        );
497        let refused = dev.metadata(&issuer).await.unwrap_err();
498        assert!(refused.to_string().contains("another host"), "{refused}");
499    }
500}