1use 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#[derive(Debug)]
18#[non_exhaustive]
19pub enum Answer {
20 Granted(Value),
22 Pending {
24 location: String,
26 retry_after: u64,
28 },
29 Denied {
31 status: u16,
33 reason: String,
35 },
36}
37
38#[derive(Clone, Copy, Debug, Default)]
41pub struct Person<'a> {
42 pub login_hint: Option<&'a str>,
44 pub tenant: Option<&'a str>,
46}
47
48impl Person<'_> {
49 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#[derive(Clone, Copy, Debug)]
69pub struct Agent<'a> {
70 pub agent_token: &'a str,
72 pub key: &'a SigningKey,
74}
75
76#[derive(Clone, Debug, Default)]
79pub struct Reach {
80 pub allow_http: bool,
82 pub allow_private: bool,
85 pub connect_to: ConnectTo,
87}
88
89#[derive(Clone, Debug)]
95pub struct PersonServerClient {
96 http: reqwest::Client,
97 reach: Reach,
98}
99
100impl PersonServerClient {
101 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 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 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 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 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 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 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 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 pub async fn poll(&self, agent: &Agent<'_>, location: &str) -> Result<Answer> {
283 self.call(agent, Method::GET, location, None).await
284 }
285
286 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
299pub 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 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}