1use std::fmt;
2use std::sync::Arc;
3
4use base64::Engine;
5use base64::engine::general_purpose::URL_SAFE_NO_PAD;
6use bytes::{Bytes, BytesMut};
7use chrono::{DateTime, TimeDelta, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use url::{Url, form_urlencoded};
11
12use crate::error::{Error, MAX_ERROR_MESSAGE_BYTES, truncate};
13use crate::http::header::{ACCEPT, CONTENT_TYPE};
14use crate::http::{Body, HttpClient, Method, Request, Response, StatusCode};
15use crate::security::require_secure_endpoint;
16use crate::types::SensitiveString;
17
18const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
19const MAX_DISCOVERY_ERROR_BYTES: usize = 4096;
20const MAX_TOKEN_RESPONSE_BYTES: usize = 1 << 20;
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct ServerMetadata {
25 pub issuer: String,
27 pub authorization_endpoint: String,
29 pub token_endpoint: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub registration_endpoint: Option<String>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub scopes_supported: Option<Vec<String>>,
37}
38
39impl ServerMetadata {
40 pub fn for_hey(base_url: &str) -> ServerMetadata {
44 let origin = base_url.trim_end_matches('/');
45 ServerMetadata {
46 issuer: origin.to_string(),
47 authorization_endpoint: format!("{origin}/oauth/authorizations/new"),
48 token_endpoint: format!("{origin}/oauth/tokens"),
49 registration_endpoint: None,
50 scopes_supported: None,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct Token {
69 pub access_token: SensitiveString,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub refresh_token: Option<SensitiveString>,
74 #[serde(default)]
76 pub token_type: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub expires_in: Option<u64>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub scope: Option<String>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub expires_at: Option<DateTime<Utc>>,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct Pkce {
93 pub verifier: SensitiveString,
95 pub challenge: String,
97}
98
99pub fn generate_pkce() -> Pkce {
101 let verifier = URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>());
102 let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
103 Pkce {
104 verifier: SensitiveString::new(verifier),
105 challenge,
106 }
107}
108
109pub fn generate_state() -> String {
111 URL_SAFE_NO_PAD.encode(rand::random::<[u8; 16]>())
112}
113
114pub fn authorization_url(
122 metadata: &ServerMetadata,
123 client_id: &str,
124 redirect_uri: &str,
125 scope: Option<&str>,
126 state: &str,
127 pkce: &Pkce,
128 install_id: &str,
129) -> Result<Url, Error> {
130 let mut url = Url::parse(&metadata.authorization_endpoint)?;
131 {
132 let mut query = url.query_pairs_mut();
133 query.append_pair("client_id", client_id);
134 query.append_pair("grant_type", "authorization_code");
135 query.append_pair("redirect_uri", redirect_uri);
136 if let Some(scope) = scope {
137 query.append_pair("scope", scope);
138 }
139 query.append_pair("state", state);
140 query.append_pair("code_challenge", &pkce.challenge);
141 query.append_pair("code_challenge_method", "S256");
142 query.append_pair("install_id", install_id);
143 }
144 Ok(url)
145}
146
147#[derive(Debug, Clone, Default)]
150pub struct ExchangeRequest {
151 pub token_endpoint: String,
153 pub code: SensitiveString,
155 pub redirect_uri: String,
157 pub client_id: String,
159 pub client_secret: Option<SensitiveString>,
161 pub code_verifier: SensitiveString,
164 pub install_id: String,
167}
168
169#[derive(Debug, Clone, Default)]
171pub struct RefreshRequest {
172 pub token_endpoint: String,
174 pub refresh_token: SensitiveString,
176 pub client_id: String,
178 pub client_secret: Option<SensitiveString>,
180 pub install_id: String,
183}
184
185#[derive(Clone)]
191pub struct OAuthClient {
192 http: Arc<dyn HttpClient>,
193}
194
195impl OAuthClient {
196 pub fn new(http: impl HttpClient + 'static) -> OAuthClient {
198 OAuthClient {
199 http: Arc::new(http),
200 }
201 }
202
203 pub async fn discover(&self, base_url: &str) -> Result<ServerMetadata, Error> {
206 let url = format!(
207 "{}/.well-known/oauth-authorization-server",
208 base_url.trim_end_matches('/')
209 );
210 let request = Request::builder()
211 .method(Method::GET)
212 .uri(url)
213 .header(ACCEPT, "application/json")
214 .body(Bytes::new())
215 .map_err(Error::from_std)?;
216 let response = self.http.send(request).await?;
217
218 let status = response.status();
219 if status == StatusCode::OK {
220 let body = read_capped(response, MAX_TOKEN_RESPONSE_BYTES).await?;
221 Ok(serde_json::from_slice(&body)?)
222 } else {
223 let body = read_truncated(response, MAX_DISCOVERY_ERROR_BYTES).await;
224 Err(Error::api(
225 status.as_u16(),
226 format!("OAuth discovery failed with status {status}"),
227 )
228 .with_hint(body))
229 }
230 }
231
232 pub async fn exchange(&self, request: &ExchangeRequest) -> Result<Token, Error> {
235 require(&request.token_endpoint, "token endpoint is required")?;
236 require(request.code.expose(), "authorization code is required")?;
237 require(&request.redirect_uri, "redirect URI is required")?;
238 require(&request.client_id, "client ID is required")?;
239 require(&request.install_id, "install ID is required")?;
240
241 self.post_token_request(&request.token_endpoint, exchange_form(request))
242 .await
243 }
244
245 pub async fn refresh(&self, request: &RefreshRequest) -> Result<Token, Error> {
248 require(&request.token_endpoint, "token endpoint is required")?;
249 require(request.refresh_token.expose(), "refresh token is required")?;
250 require(&request.install_id, "install ID is required")?;
251
252 self.post_token_request(&request.token_endpoint, refresh_form(request))
253 .await
254 }
255
256 async fn post_token_request(&self, token_endpoint: &str, form: String) -> Result<Token, Error> {
257 let url = Url::parse(token_endpoint)?;
258 require_secure_endpoint(&url)?;
259
260 let request = Request::builder()
261 .method(Method::POST)
262 .uri(url.as_str())
263 .header(ACCEPT, "application/json")
264 .header(CONTENT_TYPE, FORM_CONTENT_TYPE)
265 .body(Bytes::from(form))
266 .map_err(Error::from_std)?;
267 let response = self.http.send(request).await?;
268
269 let status = response.status();
270 let body = read_capped(response, MAX_TOKEN_RESPONSE_BYTES).await?;
271 if status == StatusCode::OK {
272 let mut token: Token = serde_json::from_slice(&body)?;
273 token.expires_at = token.expires_in.and_then(expires_at);
274 Ok(token)
275 } else {
276 Err(token_error(status, &body))
277 }
278 }
279}
280
281fn require(value: &str, message: &str) -> Result<(), Error> {
282 if value.is_empty() {
283 Err(Error::usage(message))
284 } else {
285 Ok(())
286 }
287}
288
289fn exchange_form(request: &ExchangeRequest) -> String {
290 let mut form = form_urlencoded::Serializer::new(String::new());
291 form.append_pair("grant_type", "authorization_code");
292 form.append_pair("code", request.code.expose());
293 form.append_pair("redirect_uri", &request.redirect_uri);
294 form.append_pair("client_id", &request.client_id);
295 if let Some(secret) = &request.client_secret {
296 form.append_pair("client_secret", secret.expose());
297 }
298 if !request.code_verifier.is_empty() {
299 form.append_pair("code_verifier", request.code_verifier.expose());
300 }
301 form.append_pair("install_id", &request.install_id);
302 form.finish()
303}
304
305fn refresh_form(request: &RefreshRequest) -> String {
306 let mut form = form_urlencoded::Serializer::new(String::new());
307 form.append_pair("grant_type", "refresh_token");
308 form.append_pair("refresh_token", request.refresh_token.expose());
309 if !request.client_id.is_empty() {
310 form.append_pair("client_id", &request.client_id);
311 }
312 if let Some(secret) = &request.client_secret {
313 form.append_pair("client_secret", secret.expose());
314 }
315 form.append_pair("install_id", &request.install_id);
316 form.finish()
317}
318
319impl fmt::Debug for OAuthClient {
320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321 f.debug_struct("OAuthClient").finish_non_exhaustive()
322 }
323}
324
325#[cfg(feature = "reqwest")]
326#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
327impl Default for OAuthClient {
328 fn default() -> OAuthClient {
329 OAuthClient::new(crate::http::ReqwestClient::default())
330 }
331}
332
333async fn read_capped(response: Response<Body>, limit: usize) -> Result<Bytes, Error> {
334 response
335 .into_body()
336 .collect(limit, || {
337 Error::api(0, format!("OAuth response body exceeds {limit} bytes"))
338 })
339 .await
340}
341
342async fn read_truncated(response: Response<Body>, limit: usize) -> String {
345 let mut stream = response.into_body();
346 let mut body = BytesMut::new();
347 while body.len() < limit {
348 match stream.chunk().await {
349 Ok(Some(chunk)) => body.extend_from_slice(&chunk),
350 Ok(None) | Err(_) => break,
351 }
352 }
353 body.truncate(limit);
354 String::from_utf8_lossy(&body).into_owned()
355}
356
357fn expires_at(seconds: u64) -> Option<DateTime<Utc>> {
358 let lifetime = TimeDelta::try_seconds(i64::try_from(seconds).ok()?)?;
359 Utc::now().checked_add_signed(lifetime)
360}
361
362fn token_error(status: StatusCode, body: &[u8]) -> Error {
363 match serde_json::from_slice::<TokenErrorResponse>(body) {
364 Ok(response) if !response.error.is_empty() => {
365 let description = response.error_description.unwrap_or_default();
366 if description.is_empty() {
367 Error::auth(format!("token error: {}", response.error)).with_status(status.as_u16())
368 } else {
369 let description = truncate(&description, MAX_ERROR_MESSAGE_BYTES);
370 Error::auth(format!("token error: {}", response.error))
371 .with_hint(description)
372 .with_status(status.as_u16())
373 }
374 }
375 _ => {
376 let body = truncate(&String::from_utf8_lossy(body), MAX_ERROR_MESSAGE_BYTES);
377 Error::auth(format!("token request failed with status {status}"))
378 .with_hint(body)
379 .with_status(status.as_u16())
380 }
381 }
382}
383
384#[derive(Deserialize)]
385struct TokenErrorResponse {
386 #[serde(default)]
387 error: String,
388 #[serde(default)]
389 error_description: Option<String>,
390}
391
392#[cfg(test)]
393mod tests {
394 use serde_json::json;
395 #[cfg(feature = "reqwest")]
396 use wiremock::matchers::{body_string_contains, header, method, path};
397 #[cfg(feature = "reqwest")]
398 use wiremock::{Mock, MockServer, ResponseTemplate};
399
400 use super::*;
401
402 #[test]
403 fn pkce_challenge_is_the_digest_of_the_verifier() {
404 let pkce = generate_pkce();
405
406 assert_eq!(
407 32,
408 URL_SAFE_NO_PAD
409 .decode(pkce.verifier.expose())
410 .unwrap()
411 .len()
412 );
413 assert_eq!(
414 URL_SAFE_NO_PAD.encode(Sha256::digest(pkce.verifier.expose().as_bytes())),
415 pkce.challenge
416 );
417 assert_ne!(pkce.verifier, generate_pkce().verifier);
418 assert_eq!("[REDACTED]", format!("{:?}", pkce.verifier));
419 }
420
421 #[test]
422 fn state_is_sixteen_random_bytes() {
423 let state = generate_state();
424
425 assert_eq!(16, URL_SAFE_NO_PAD.decode(&state).unwrap().len());
426 assert_ne!(state, generate_state());
427 }
428
429 #[test]
430 fn authorization_url_carries_the_challenge_state_and_install_id() {
431 let pkce = generate_pkce();
432 let url = authorization_url(
433 &metadata("https://auth.example.com"),
434 "client-1",
435 "http://127.0.0.1:9000/callback",
436 Some("read write"),
437 "state-1",
438 &pkce,
439 "install-1",
440 )
441 .unwrap();
442
443 let query: Vec<(String, String)> = url
444 .query_pairs()
445 .map(|(name, value)| (name.into_owned(), value.into_owned()))
446 .collect();
447 assert_eq!("https", url.scheme());
448 assert_eq!("/authorize", url.path());
449 assert_eq!(
450 vec![
451 ("client_id".to_string(), "client-1".to_string()),
452 ("grant_type".to_string(), "authorization_code".to_string()),
453 (
454 "redirect_uri".to_string(),
455 "http://127.0.0.1:9000/callback".to_string()
456 ),
457 ("scope".to_string(), "read write".to_string()),
458 ("state".to_string(), "state-1".to_string()),
459 ("code_challenge".to_string(), pkce.challenge.clone()),
460 ("code_challenge_method".to_string(), "S256".to_string()),
461 ("install_id".to_string(), "install-1".to_string()),
462 ],
463 query
464 );
465 }
466
467 #[test]
468 fn hey_metadata_points_at_its_oauth_routes() {
469 let metadata = ServerMetadata::for_hey("https://app.hey.com/");
470
471 assert_eq!("https://app.hey.com", metadata.issuer);
472 assert_eq!(
473 "https://app.hey.com/oauth/authorizations/new",
474 metadata.authorization_endpoint
475 );
476 assert_eq!("https://app.hey.com/oauth/tokens", metadata.token_endpoint);
477 }
478
479 #[test]
480 fn authorization_url_leaves_out_an_absent_scope() {
481 let url = authorization_url(
482 &metadata("https://auth.example.com"),
483 "client-1",
484 "http://127.0.0.1:9000/callback",
485 None,
486 "state-1",
487 &generate_pkce(),
488 "install-1",
489 )
490 .unwrap();
491
492 assert!(!url.query().unwrap().contains("scope"));
493 }
494
495 #[cfg(feature = "reqwest")]
496 #[tokio::test]
497 async fn discover_reads_the_well_known_document() {
498 let server = MockServer::start().await;
499 Mock::given(method("GET"))
500 .and(path("/.well-known/oauth-authorization-server"))
501 .and(header("accept", "application/json"))
502 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
503 "issuer": "https://auth.example.com",
504 "authorization_endpoint": "https://auth.example.com/authorize",
505 "token_endpoint": "https://auth.example.com/token",
506 "scopes_supported": [ "read", "write" ]
507 })))
508 .mount(&server)
509 .await;
510
511 let metadata = OAuthClient::default()
512 .discover(&server.uri())
513 .await
514 .unwrap();
515
516 assert_eq!("https://auth.example.com/token", metadata.token_endpoint);
517 assert_eq!(
518 Some(vec!["read".to_string(), "write".to_string()]),
519 metadata.scopes_supported
520 );
521 assert_eq!(None, metadata.registration_endpoint);
522 }
523
524 #[cfg(feature = "reqwest")]
525 #[tokio::test]
526 async fn discover_reports_the_failing_status() {
527 let server = MockServer::start().await;
528 Mock::given(method("GET"))
529 .respond_with(ResponseTemplate::new(404).set_body_string("no such server"))
530 .mount(&server)
531 .await;
532
533 let error = OAuthClient::default()
534 .discover(&server.uri())
535 .await
536 .unwrap_err();
537
538 assert_eq!(Some(404), error.http_status());
539 assert_eq!(Some("no such server"), error.hint());
540 }
541
542 #[cfg(feature = "reqwest")]
543 #[tokio::test]
544 async fn exchange_trades_a_code_for_a_token() {
545 let server = MockServer::start().await;
546 Mock::given(method("POST"))
547 .and(path("/token"))
548 .and(header("content-type", FORM_CONTENT_TYPE))
549 .and(body_string_contains("grant_type=authorization_code"))
550 .and(body_string_contains("code_verifier=verifier-1"))
551 .and(body_string_contains("code=code-1"))
552 .and(body_string_contains("install_id=install-1"))
553 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
554 "access_token": "access-1",
555 "refresh_token": "refresh-1",
556 "token_type": "Bearer",
557 "expires_in": 3600,
558 "scope": "read"
559 })))
560 .mount(&server)
561 .await;
562
563 let request = ExchangeRequest {
564 token_endpoint: format!("{}/token", server.uri()),
565 code: "code-1".into(),
566 redirect_uri: "http://127.0.0.1:9000/callback".to_string(),
567 client_id: "client-1".to_string(),
568 client_secret: None,
569 code_verifier: "verifier-1".into(),
570 install_id: "install-1".to_string(),
571 };
572 let token = OAuthClient::default().exchange(&request).await.unwrap();
573
574 assert_eq!("access-1", token.access_token.expose());
575 assert_eq!(
576 Some("refresh-1"),
577 token.refresh_token.as_ref().map(SensitiveString::expose)
578 );
579 assert_eq!(Some(3600), token.expires_in);
580 assert!(token.expires_at.unwrap() > Utc::now());
581 }
582
583 #[test]
584 fn a_token_keeps_the_moment_it_expires_across_being_stored() {
585 let answered = serde_json::from_value::<Token>(json!({
586 "access_token": "access-1",
587 "token_type": "Bearer",
588 "expires_in": 3600
589 }))
590 .unwrap();
591 assert_eq!(None, answered.expires_at);
592
593 let mut token = answered;
594 token.expires_at = Utc::now().checked_add_signed(TimeDelta::seconds(3600));
595
596 let stored = serde_json::to_string(&token).unwrap();
597 let restored: Token = serde_json::from_str(&stored).unwrap();
598
599 assert_eq!(token, restored);
600 assert_eq!(token.expires_at, restored.expires_at);
601 }
602
603 #[cfg(feature = "reqwest")]
604 #[tokio::test]
605 async fn exchange_reports_the_server_error() {
606 let server = MockServer::start().await;
607 Mock::given(method("POST"))
608 .respond_with(ResponseTemplate::new(400).set_body_json(json!({
609 "error": "invalid_grant",
610 "error_description": "The authorization code has expired"
611 })))
612 .mount(&server)
613 .await;
614
615 let request = ExchangeRequest {
616 token_endpoint: format!("{}/token", server.uri()),
617 code: "code-1".into(),
618 redirect_uri: "http://127.0.0.1:9000/callback".to_string(),
619 client_id: "client-1".to_string(),
620 client_secret: None,
621 code_verifier: "verifier-1".into(),
622 install_id: "install-1".to_string(),
623 };
624 let error = OAuthClient::default().exchange(&request).await.unwrap_err();
625
626 assert_eq!(crate::error::ErrorCode::Auth, error.code());
627 assert_eq!("token error: invalid_grant", error.message());
628 assert_eq!(Some("The authorization code has expired"), error.hint());
629 assert_eq!(Some(400), error.http_status());
630 }
631
632 #[cfg(feature = "reqwest")]
633 #[tokio::test]
634 async fn exchange_wants_its_required_fields() {
635 let error = OAuthClient::default()
636 .exchange(&ExchangeRequest::default())
637 .await
638 .unwrap_err();
639
640 assert_eq!(crate::error::ErrorCode::Usage, error.code());
641 assert_eq!("token endpoint is required", error.message());
642 }
643
644 #[cfg(feature = "reqwest")]
645 #[tokio::test]
646 async fn exchange_wants_an_install_id() {
647 let request = ExchangeRequest {
648 token_endpoint: "https://auth.example.com/token".to_string(),
649 code: "code-1".into(),
650 redirect_uri: "http://127.0.0.1:9000/callback".to_string(),
651 client_id: "client-1".to_string(),
652 client_secret: None,
653 code_verifier: "verifier-1".into(),
654 install_id: String::new(),
655 };
656 let error = OAuthClient::default().exchange(&request).await.unwrap_err();
657
658 assert_eq!(crate::error::ErrorCode::Usage, error.code());
659 assert_eq!("install ID is required", error.message());
660 }
661
662 #[cfg(feature = "reqwest")]
663 #[tokio::test]
664 async fn refresh_trades_a_refresh_token_for_a_token() {
665 let server = MockServer::start().await;
666 Mock::given(method("POST"))
667 .and(path("/token"))
668 .and(body_string_contains("grant_type=refresh_token"))
669 .and(body_string_contains("refresh_token=refresh-1"))
670 .and(body_string_contains("client_id=client-1"))
671 .and(body_string_contains("install_id=install-1"))
672 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
673 "access_token": "access-2",
674 "token_type": "Bearer",
675 "expires_in": 900
676 })))
677 .mount(&server)
678 .await;
679
680 let request = RefreshRequest {
681 token_endpoint: format!("{}/token", server.uri()),
682 refresh_token: "refresh-1".into(),
683 client_id: "client-1".to_string(),
684 client_secret: None,
685 install_id: "install-1".to_string(),
686 };
687 let token = OAuthClient::default().refresh(&request).await.unwrap();
688
689 assert_eq!("access-2", token.access_token.expose());
690 assert_eq!(None, token.refresh_token);
691 assert!(token.expires_at.is_some());
692 }
693
694 #[cfg(feature = "reqwest")]
695 #[tokio::test]
696 async fn refresh_reports_an_unparsable_error_body() {
697 let server = MockServer::start().await;
698 Mock::given(method("POST"))
699 .respond_with(ResponseTemplate::new(503).set_body_string("upstream is down"))
700 .mount(&server)
701 .await;
702
703 let request = RefreshRequest {
704 token_endpoint: format!("{}/token", server.uri()),
705 refresh_token: "refresh-1".into(),
706 client_id: "client-1".to_string(),
707 client_secret: None,
708 install_id: "install-1".to_string(),
709 };
710 let error = OAuthClient::default().refresh(&request).await.unwrap_err();
711
712 assert_eq!(
713 "token request failed with status 503 Service Unavailable",
714 error.message()
715 );
716 assert_eq!(Some("upstream is down"), error.hint());
717 }
718
719 #[cfg(feature = "reqwest")]
720 #[tokio::test]
721 async fn a_plain_http_token_endpoint_is_refused() {
722 let request = RefreshRequest {
723 token_endpoint: "http://auth.example.com/token".to_string(),
724 refresh_token: "refresh-1".into(),
725 client_id: "client-1".to_string(),
726 client_secret: None,
727 install_id: "install-1".to_string(),
728 };
729 let error = OAuthClient::default().refresh(&request).await.unwrap_err();
730
731 assert_eq!(crate::error::ErrorCode::Usage, error.code());
732 }
733
734 fn metadata(issuer: &str) -> ServerMetadata {
735 ServerMetadata {
736 issuer: issuer.to_string(),
737 authorization_endpoint: format!("{issuer}/authorize"),
738 token_endpoint: format!("{issuer}/token"),
739 registration_endpoint: None,
740 scopes_supported: None,
741 }
742 }
743}