Skip to main content

via/auth/
github_app.rs

1use std::env;
2use std::fs::{self, OpenOptions};
3use std::io::{self, Write};
4#[cfg(unix)]
5use std::os::unix::fs::PermissionsExt;
6use std::path::{Path, PathBuf};
7use std::thread;
8use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
9
10use reqwest::blocking::Client;
11use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
12use ring::digest::{Context, SHA256};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use time::format_description::well_known::Rfc3339;
16use time::OffsetDateTime;
17
18use crate::auth::jwt;
19use crate::error::ViaError;
20use crate::redaction::Redactor;
21use crate::secrets::SecretValue;
22
23const CACHE_EXPIRY_SKEW_SECONDS: i64 = 60;
24const CACHE_LOCK_WAIT: Duration = Duration::from_secs(10);
25const CACHE_LOCK_POLL: Duration = Duration::from_millis(50);
26const CACHE_LOCK_STALE_AFTER: Duration = Duration::from_secs(60);
27
28pub fn installation_access_token(
29    client: &Client,
30    api_base_url: &str,
31    credential: &SecretValue,
32    private_key: Option<&SecretValue>,
33    redactor: &mut Redactor,
34) -> Result<String, ViaError> {
35    redactor.add(credential.expose());
36    if let Some(private_key) = private_key {
37        redactor.add(private_key.expose());
38    }
39
40    let bundle =
41        CredentialBundle::parse(credential.expose(), private_key.map(SecretValue::expose))?;
42    bundle.validate_kind()?;
43
44    if let Some(cache_dir) = default_cache_dir() {
45        return installation_access_token_with_cache_dir(
46            client,
47            api_base_url,
48            &bundle,
49            redactor,
50            &cache_dir,
51        );
52    }
53
54    crate::timing::event("github_app token cache", "disabled");
55    exchange_installation_access_token(client, api_base_url, &bundle, redactor)
56        .map(|token| token.token)
57}
58
59fn installation_access_token_with_cache_dir(
60    client: &Client,
61    api_base_url: &str,
62    bundle: &CredentialBundle,
63    redactor: &mut Redactor,
64    cache_dir: &Path,
65) -> Result<String, ViaError> {
66    let now = unix_timestamp()?;
67    let key = cache_key(api_base_url, bundle);
68    let cache_path = token_cache_path(cache_dir, &key);
69
70    let cache_span = crate::timing::span("github_app token cache read");
71    if let Some(token) = read_cached_token(&cache_path, now) {
72        cache_span.finish("hit");
73        redactor.add(&token);
74        return Ok(token);
75    }
76    cache_span.finish("miss");
77
78    let lock_path = token_lock_path(cache_dir, &key);
79    let lock_span = crate::timing::span("github_app token cache lock");
80    if let Some(_lock) = CacheLock::acquire(&lock_path) {
81        lock_span.finish("acquired");
82        let now = unix_timestamp()?;
83        let cache_span = crate::timing::span("github_app token cache read_after_lock");
84        if let Some(token) = read_cached_token(&cache_path, now) {
85            cache_span.finish("hit");
86            redactor.add(&token);
87            return Ok(token);
88        }
89        cache_span.finish("miss");
90
91        let token = exchange_installation_access_token(client, api_base_url, bundle, redactor)?;
92        let write_span = crate::timing::span("github_app token cache write");
93        match write_cached_token(
94            &cache_path,
95            &CachedInstallationToken {
96                token: token.token.clone(),
97                expires_at: token.expires_at,
98            },
99        ) {
100            Ok(()) => write_span.finish("ok"),
101            Err(_) => write_span.finish("failed"),
102        };
103        return Ok(token.token);
104    }
105
106    lock_span.finish("unavailable");
107    exchange_installation_access_token(client, api_base_url, bundle, redactor)
108        .map(|token| token.token)
109}
110
111fn exchange_installation_access_token(
112    client: &Client,
113    api_base_url: &str,
114    bundle: &CredentialBundle,
115    redactor: &mut Redactor,
116) -> Result<InstallationAccessToken, ViaError> {
117    redactor.add(&bundle.private_key);
118    let jwt_span = crate::timing::span("github_app jwt sign");
119    let jwt = app_jwt(bundle)?;
120    jwt_span.finish("ok");
121    redactor.add(&jwt);
122
123    let url = format!(
124        "{}/app/installations/{}/access_tokens",
125        api_base_url.trim_end_matches('/'),
126        bundle.installation_id
127    );
128    let exchange_span = crate::timing::span("github_app installation token exchange");
129    let response = match client
130        .post(url)
131        .headers(token_exchange_headers(&jwt)?)
132        .send()
133    {
134        Ok(response) => {
135            let status = response.status();
136            exchange_span.finish(format!("status={status}"));
137            response
138        }
139        Err(error) => {
140            exchange_span.finish("failed");
141            return Err(error.into());
142        }
143    };
144    let status = response.status();
145    let body_span = crate::timing::span("github_app installation token body");
146    let body = match response.text() {
147        Ok(body) => {
148            body_span.finish(format!("bytes={}", body.len()));
149            body
150        }
151        Err(error) => {
152            body_span.finish("failed");
153            return Err(error.into());
154        }
155    };
156
157    if !status.is_success() {
158        let body = redactor.redact(&body);
159        return Err(ViaError::InvalidArgument(format!(
160            "GitHub App token exchange failed with status {status}: {body}"
161        )));
162    }
163
164    let response: InstallationTokenResponse = serde_json::from_str(&body)?;
165    let expires_at = parse_github_expires_at(&response.expires_at)?;
166    redactor.add(&response.token);
167    Ok(InstallationAccessToken {
168        token: response.token,
169        expires_at,
170    })
171}
172
173pub fn validate_credential_bundle(raw: &str, private_key: Option<&str>) -> Result<(), ViaError> {
174    let bundle = CredentialBundle::parse(raw, private_key)?;
175    bundle.validate_kind()?;
176    app_jwt(&bundle)?;
177    Ok(())
178}
179
180fn app_jwt(bundle: &CredentialBundle) -> Result<String, ViaError> {
181    let now = unix_timestamp()?;
182    let claims = serde_json::json!({
183        "iat": now - 60,
184        "exp": now + 540,
185        "iss": bundle.issuer,
186    });
187    jwt::sign_rs256(&claims, &bundle.private_key)
188}
189
190fn token_exchange_headers(jwt: &str) -> Result<HeaderMap, ViaError> {
191    let mut headers = HeaderMap::new();
192    headers.insert(
193        ACCEPT,
194        HeaderValue::from_static("application/vnd.github+json"),
195    );
196    headers.insert(USER_AGENT, HeaderValue::from_static("via-cli"));
197    headers.insert(
198        "X-GitHub-Api-Version",
199        HeaderValue::from_static("2022-11-28"),
200    );
201    headers.insert(
202        AUTHORIZATION,
203        HeaderValue::from_str(&format!("Bearer {jwt}"))
204            .map_err(|_| ViaError::InvalidConfig("invalid GitHub App JWT".to_owned()))?,
205    );
206    Ok(headers)
207}
208
209fn unix_timestamp() -> Result<i64, ViaError> {
210    let duration = SystemTime::now()
211        .duration_since(UNIX_EPOCH)
212        .map_err(|_| ViaError::InvalidConfig("system clock is before UNIX epoch".to_owned()))?;
213    i64::try_from(duration.as_secs())
214        .map_err(|_| ViaError::InvalidConfig("system clock timestamp is too large".to_owned()))
215}
216
217#[derive(Debug, PartialEq, Eq)]
218struct CredentialBundle {
219    kind: String,
220    issuer: String,
221    installation_id: String,
222    private_key: String,
223}
224
225impl CredentialBundle {
226    fn parse(raw: &str, private_key: Option<&str>) -> Result<Self, ViaError> {
227        let value: Value = serde_json::from_str(raw).map_err(credential_json_error)?;
228
229        Ok(Self {
230            kind: required_string(&value, "type")?,
231            issuer: required_app_id(&value)?,
232            installation_id: required_string_or_number(&value, "installation_id")?,
233            private_key: match private_key {
234                Some(private_key) => private_key.to_owned(),
235                None => required_string(&value, "private_key")?,
236            },
237        })
238    }
239
240    fn validate_kind(&self) -> Result<(), ViaError> {
241        if self.kind == "github_app" {
242            return Ok(());
243        }
244
245        Err(ViaError::InvalidConfig(
246            "GitHub App credential bundle must set type = \"github_app\"".to_owned(),
247        ))
248    }
249}
250
251#[derive(Debug, Deserialize)]
252struct InstallationTokenResponse {
253    token: String,
254    expires_at: String,
255}
256
257struct InstallationAccessToken {
258    token: String,
259    expires_at: i64,
260}
261
262#[derive(Debug, Deserialize, Serialize)]
263struct CachedInstallationToken {
264    token: String,
265    expires_at: i64,
266}
267
268struct CacheLock {
269    path: PathBuf,
270}
271
272impl CacheLock {
273    fn acquire(path: &Path) -> Option<Self> {
274        if let Some(parent) = path.parent() {
275            create_private_dir(parent).ok()?;
276        }
277
278        let started = Instant::now();
279        loop {
280            match OpenOptions::new().write(true).create_new(true).open(path) {
281                Ok(mut file) => {
282                    let _ = set_private_file_permissions(path);
283                    let _ = writeln!(file, "{}", std::process::id());
284                    return Some(Self {
285                        path: path.to_path_buf(),
286                    });
287                }
288                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
289                    if lock_is_stale(path) {
290                        let _ = fs::remove_file(path);
291                        continue;
292                    }
293
294                    if started.elapsed() >= CACHE_LOCK_WAIT {
295                        return None;
296                    }
297
298                    thread::sleep(CACHE_LOCK_POLL);
299                }
300                Err(_) => return None,
301            }
302        }
303    }
304}
305
306impl Drop for CacheLock {
307    fn drop(&mut self) {
308        let _ = fs::remove_file(&self.path);
309    }
310}
311
312fn default_cache_dir() -> Option<PathBuf> {
313    env_path("VIA_CACHE_DIR")
314        .or_else(|| env_path("XDG_CACHE_HOME").map(|path| path.join("via")))
315        .or_else(|| env_path("HOME").map(|path| path.join(".cache").join("via")))
316}
317
318fn env_path(name: &str) -> Option<PathBuf> {
319    env::var_os(name)
320        .filter(|value| !value.as_os_str().is_empty())
321        .map(PathBuf::from)
322}
323
324fn token_cache_path(cache_dir: &Path, key: &str) -> PathBuf {
325    cache_dir.join("github-app").join(format!("{key}.json"))
326}
327
328fn token_lock_path(cache_dir: &Path, key: &str) -> PathBuf {
329    cache_dir.join("github-app").join(format!("{key}.lock"))
330}
331
332fn cache_key(api_base_url: &str, bundle: &CredentialBundle) -> String {
333    let mut context = Context::new(&SHA256);
334    context.update(api_base_url.trim_end_matches('/').as_bytes());
335    context.update(b"\0");
336    context.update(bundle.issuer.as_bytes());
337    context.update(b"\0");
338    context.update(bundle.installation_id.as_bytes());
339    hex_encode(context.finish().as_ref())
340}
341
342fn read_cached_token(path: &Path, now: i64) -> Option<String> {
343    let raw = fs::read_to_string(path).ok()?;
344    let cached: CachedInstallationToken = serde_json::from_str(&raw).ok()?;
345    if cached.expires_at <= now + CACHE_EXPIRY_SKEW_SECONDS {
346        return None;
347    }
348    Some(cached.token)
349}
350
351fn write_cached_token(path: &Path, token: &CachedInstallationToken) -> Result<(), ViaError> {
352    let parent = path
353        .parent()
354        .ok_or_else(|| ViaError::InvalidConfig("cache path has no parent".to_owned()))?;
355    create_private_dir(parent)?;
356
357    let temp_path = path.with_file_name(format!(
358        ".{}.{}.tmp",
359        path.file_name()
360            .and_then(|name| name.to_str())
361            .unwrap_or("token"),
362        std::process::id()
363    ));
364    let raw = serde_json::to_vec(token)?;
365    {
366        let mut file = OpenOptions::new()
367            .write(true)
368            .create(true)
369            .truncate(true)
370            .open(&temp_path)?;
371        let _ = set_private_file_permissions(&temp_path);
372        file.write_all(&raw)?;
373        file.sync_all()?;
374    }
375
376    match fs::rename(&temp_path, path) {
377        Ok(()) => Ok(()),
378        Err(error) => {
379            if error.kind() == io::ErrorKind::AlreadyExists {
380                fs::remove_file(path)?;
381                fs::rename(&temp_path, path)?;
382                Ok(())
383            } else {
384                let _ = fs::remove_file(&temp_path);
385                Err(error.into())
386            }
387        }
388    }
389}
390
391fn create_private_dir(path: &Path) -> io::Result<()> {
392    fs::create_dir_all(path)?;
393    set_private_dir_permissions(path)
394}
395
396#[cfg(unix)]
397fn set_private_dir_permissions(path: &Path) -> io::Result<()> {
398    fs::set_permissions(path, fs::Permissions::from_mode(0o700))
399}
400
401#[cfg(not(unix))]
402fn set_private_dir_permissions(_path: &Path) -> io::Result<()> {
403    Ok(())
404}
405
406#[cfg(unix)]
407fn set_private_file_permissions(path: &Path) -> io::Result<()> {
408    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
409}
410
411#[cfg(not(unix))]
412fn set_private_file_permissions(_path: &Path) -> io::Result<()> {
413    Ok(())
414}
415
416fn lock_is_stale(path: &Path) -> bool {
417    path.metadata()
418        .and_then(|metadata| metadata.modified())
419        .and_then(|modified| modified.elapsed().map_err(io::Error::other))
420        .is_ok_and(|age| age >= CACHE_LOCK_STALE_AFTER)
421}
422
423fn hex_encode(bytes: &[u8]) -> String {
424    const HEX: &[u8; 16] = b"0123456789abcdef";
425    let mut encoded = String::with_capacity(bytes.len() * 2);
426    for byte in bytes {
427        encoded.push(HEX[(byte >> 4) as usize] as char);
428        encoded.push(HEX[(byte & 0x0f) as usize] as char);
429    }
430    encoded
431}
432
433fn parse_github_expires_at(value: &str) -> Result<i64, ViaError> {
434    OffsetDateTime::parse(value, &Rfc3339)
435        .map(OffsetDateTime::unix_timestamp)
436        .map_err(|error| {
437            ViaError::InvalidArgument(format!(
438                "GitHub App token response had invalid `expires_at` `{value}`: {error}"
439            ))
440        })
441}
442
443fn credential_json_error(error: serde_json::Error) -> ViaError {
444    let mut message = format!("GitHub App credential bundle must be valid JSON: {error}");
445    if error.to_string().contains("control character") {
446        message.push_str(
447            "; private_key must escape PEM newlines as `\\n`, not contain raw line breaks inside the JSON string",
448        );
449    }
450    ViaError::InvalidConfig(message)
451}
452
453fn required_string(value: &Value, field: &str) -> Result<String, ViaError> {
454    value
455        .get(field)
456        .and_then(Value::as_str)
457        .filter(|value| !value.trim().is_empty())
458        .map(str::to_owned)
459        .ok_or_else(|| {
460            ViaError::InvalidConfig(format!(
461                "GitHub App credential bundle must include non-empty `{field}`"
462            ))
463        })
464}
465
466fn required_app_id(value: &Value) -> Result<String, ViaError> {
467    if let Some(number) = value.get("app_id").and_then(Value::as_u64) {
468        return Ok(number.to_string());
469    }
470    if let Some(app_id) = value
471        .get("app_id")
472        .and_then(Value::as_str)
473        .filter(|value| value.chars().all(|character| character.is_ascii_digit()))
474    {
475        return Ok(app_id.to_owned());
476    }
477
478    if value.get("client_id").is_some() {
479        return Err(ViaError::InvalidConfig(
480            "GitHub App credential bundle must include numeric `app_id`; `client_id` is metadata only and is not used for this token exchange".to_owned(),
481        ));
482    }
483
484    Err(ViaError::InvalidConfig(
485        "GitHub App credential bundle must include numeric `app_id`".to_owned(),
486    ))
487}
488
489fn required_string_or_number(value: &Value, field: &str) -> Result<String, ViaError> {
490    if let Some(value) = value
491        .get(field)
492        .and_then(Value::as_str)
493        .filter(|value| !value.trim().is_empty())
494    {
495        return Ok(value.to_owned());
496    }
497    if let Some(number) = value.get(field).and_then(Value::as_u64) {
498        return Ok(number.to_string());
499    }
500
501    Err(ViaError::InvalidConfig(format!(
502        "GitHub App credential bundle must include non-empty `{field}`"
503    )))
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use std::io::Read;
510    use std::net::TcpListener;
511
512    const PRIVATE_KEY: &str = include_str!("../../tests/fixtures/rsa-private-key.pkcs1.pem");
513
514    #[test]
515    fn parses_bundle_with_app_id_string() {
516        let bundle = CredentialBundle::parse(
517            &serde_json::json!({
518                "type": "github_app",
519                "app_id": "42",
520                "installation_id": "123",
521                "private_key": PRIVATE_KEY,
522            })
523            .to_string(),
524            None,
525        )
526        .unwrap();
527
528        assert_eq!(
529            bundle,
530            CredentialBundle {
531                kind: "github_app".to_owned(),
532                issuer: "42".to_owned(),
533                installation_id: "123".to_owned(),
534                private_key: PRIVATE_KEY.to_owned(),
535            }
536        );
537    }
538
539    #[test]
540    fn parses_numeric_app_and_installation_ids() {
541        let bundle = CredentialBundle::parse(
542            &serde_json::json!({
543                "type": "github_app",
544                "app_id": 42,
545                "installation_id": 123,
546                "private_key": PRIVATE_KEY,
547            })
548            .to_string(),
549            None,
550        )
551        .unwrap();
552
553        assert_eq!(bundle.issuer, "42");
554        assert_eq!(bundle.installation_id, "123");
555    }
556
557    #[test]
558    fn rejects_missing_private_key() {
559        let error = CredentialBundle::parse(
560            &serde_json::json!({
561                "type": "github_app",
562                "app_id": 42,
563                "installation_id": "123",
564            })
565            .to_string(),
566            None,
567        )
568        .unwrap_err();
569
570        assert!(
571            matches!(error, ViaError::InvalidConfig(message) if message.contains("private_key"))
572        );
573    }
574
575    #[test]
576    fn explains_raw_newlines_inside_private_key_json() {
577        let error = CredentialBundle::parse(
578            r#"{
579  "type": "github_app",
580  "app_id": 42,
581  "installation_id": "123",
582  "private_key": "-----BEGIN RSA PRIVATE KEY-----
583abc
584-----END RSA PRIVATE KEY-----"
585}"#,
586            None,
587        )
588        .unwrap_err();
589
590        assert!(
591            matches!(error, ViaError::InvalidConfig(message) if message.contains("escape PEM newlines"))
592        );
593    }
594
595    #[test]
596    fn validates_bundle_and_private_key() {
597        validate_credential_bundle(
598            &serde_json::json!({
599                "type": "github_app",
600                "app_id": 42,
601                "installation_id": "123",
602                "private_key": PRIVATE_KEY,
603            })
604            .to_string(),
605            None,
606        )
607        .unwrap();
608    }
609
610    #[test]
611    fn validates_split_metadata_and_private_key() {
612        validate_credential_bundle(
613            &serde_json::json!({
614                "type": "github_app",
615                "app_id": 42,
616                "installation_id": "123",
617            })
618            .to_string(),
619            Some(PRIVATE_KEY),
620        )
621        .unwrap();
622    }
623
624    #[test]
625    fn creates_app_jwt() {
626        let bundle = CredentialBundle {
627            kind: "github_app".to_owned(),
628            issuer: "42".to_owned(),
629            installation_id: "123".to_owned(),
630            private_key: PRIVATE_KEY.to_owned(),
631        };
632
633        let token = app_jwt(&bundle).unwrap();
634
635        assert_eq!(token.split('.').count(), 3);
636    }
637
638    #[test]
639    fn rejects_client_id_without_app_id() {
640        let error = CredentialBundle::parse(
641            &serde_json::json!({
642                "type": "github_app",
643                "client_id": "Iv1.client",
644                "installation_id": "123",
645                "private_key": PRIVATE_KEY,
646            })
647            .to_string(),
648            None,
649        )
650        .unwrap_err();
651
652        assert!(matches!(
653            error,
654            ViaError::InvalidConfig(message)
655                if message.contains("numeric `app_id`") && message.contains("client_id")
656        ));
657    }
658
659    #[test]
660    fn parses_github_token_expiry() {
661        assert_eq!(parse_github_expires_at("1970-01-01T00:00:00Z").unwrap(), 0);
662        assert_eq!(
663            parse_github_expires_at("2026-05-02T12:34:56Z").unwrap(),
664            1_777_725_296
665        );
666        assert_eq!(
667            parse_github_expires_at("2026-05-02T12:34:56.789Z").unwrap(),
668            1_777_725_296
669        );
670        assert_eq!(
671            parse_github_expires_at("2026-05-02T08:34:56-04:00").unwrap(),
672            1_777_725_296
673        );
674    }
675
676    #[test]
677    fn rejects_invalid_github_token_expiry() {
678        assert!(parse_github_expires_at("2026-02-29T12:34:56Z").is_err());
679        assert!(parse_github_expires_at("not-a-date").is_err());
680    }
681
682    #[test]
683    fn returns_unexpired_cached_installation_token() {
684        let cache_dir = temp_cache_dir("hit");
685        let bundle = test_bundle();
686        let key = cache_key("https://api.github.com", &bundle);
687        let cache_path = token_cache_path(&cache_dir, &key);
688        let now = unix_timestamp().unwrap();
689        write_cached_token(
690            &cache_path,
691            &CachedInstallationToken {
692                token: "cached-token".to_owned(),
693                expires_at: now + 3_600,
694            },
695        )
696        .unwrap();
697
698        let client = Client::new();
699        let mut redactor = Redactor::new();
700        let token = installation_access_token_with_cache_dir(
701            &client,
702            "https://api.github.com",
703            &bundle,
704            &mut redactor,
705            &cache_dir,
706        )
707        .unwrap();
708
709        assert_eq!(token, "cached-token");
710        assert_eq!(redactor.redact("cached-token"), "[REDACTED]");
711        let _ = fs::remove_dir_all(cache_dir);
712    }
713
714    #[test]
715    fn exchanges_and_caches_expired_installation_token() {
716        crate::tls::install_crypto_provider();
717
718        let cache_dir = temp_cache_dir("refresh");
719        let bundle = test_bundle();
720
721        let response_body = serde_json::json!({
722            "token": "fresh-token",
723            "expires_at": "2099-01-01T00:00:00Z",
724        })
725        .to_string();
726        let (api_base_url, server) = token_server(response_body);
727        let key = cache_key(&api_base_url, &bundle);
728        let cache_path = token_cache_path(&cache_dir, &key);
729        write_cached_token(
730            &cache_path,
731            &CachedInstallationToken {
732                token: "expired-token".to_owned(),
733                expires_at: 0,
734            },
735        )
736        .unwrap();
737
738        let client = Client::new();
739        let mut redactor = Redactor::new();
740        let token = installation_access_token_with_cache_dir(
741            &client,
742            &api_base_url,
743            &bundle,
744            &mut redactor,
745            &cache_dir,
746        )
747        .unwrap();
748        let request = server.join().unwrap();
749
750        assert_eq!(token, "fresh-token");
751        assert!(request.starts_with("POST /app/installations/123/access_tokens "));
752        assert_eq!(
753            read_cached_token(&cache_path, unix_timestamp().unwrap()).as_deref(),
754            Some("fresh-token")
755        );
756        let _ = fs::remove_dir_all(cache_dir);
757    }
758
759    fn test_bundle() -> CredentialBundle {
760        CredentialBundle {
761            kind: "github_app".to_owned(),
762            issuer: "42".to_owned(),
763            installation_id: "123".to_owned(),
764            private_key: PRIVATE_KEY.to_owned(),
765        }
766    }
767
768    fn temp_cache_dir(name: &str) -> PathBuf {
769        let mut path = env::temp_dir();
770        path.push(format!(
771            "via-github-app-cache-test-{name}-{}-{}",
772            std::process::id(),
773            unix_timestamp().unwrap()
774        ));
775        let _ = fs::remove_dir_all(&path);
776        path
777    }
778
779    fn token_server(response_body: String) -> (String, thread::JoinHandle<String>) {
780        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
781        let address = listener.local_addr().unwrap();
782        let handle = thread::spawn(move || {
783            let (mut stream, _) = listener.accept().unwrap();
784            let mut buffer = [0_u8; 8192];
785            let read = stream.read(&mut buffer).unwrap();
786            let request = String::from_utf8_lossy(&buffer[..read]).to_string();
787            let response = format!(
788                "HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
789                response_body.len(),
790                response_body
791            );
792            stream.write_all(response.as_bytes()).unwrap();
793            request
794        });
795
796        (format!("http://{address}"), handle)
797    }
798}