Skip to main content

bwx/api/
client.rs

1use rand::distr::SampleString as _;
2use sha2::Digest as _;
3use tokio::io::AsyncReadExt as _;
4
5use super::sso::{
6    classify_login_error, find_free_port, start_sso_callback_server,
7};
8use super::types::{KdfType, TwoFactorProviderType};
9use super::wire::{
10    CipherCard, CipherField, CipherIdentity, CipherLogin, CipherLoginUri,
11    CipherSecureNote, CiphersPostReq, CiphersPutReq, CiphersPutReqHistory,
12    ConnectErrorRes, ConnectRefreshTokenReq, ConnectRefreshTokenRes,
13    ConnectTokenAuth, ConnectTokenAuthCode, ConnectTokenClientCredentials,
14    ConnectTokenPassword, ConnectTokenReq, ConnectTokenRes, FoldersPostReq,
15    FoldersRes, FoldersResData, PreloginReq, PreloginRes, SendEmailLoginReq,
16    SyncRes,
17};
18use crate::json::{
19    DeserializeJsonWithPath as _, DeserializeJsonWithPathAsync as _,
20};
21use crate::prelude::*;
22
23// Used for the Bitwarden-Client-Name header. Accepted values:
24// https://github.com/bitwarden/server/blob/main/src/Core/Enums/BitwardenClient.cs
25const BITWARDEN_CLIENT: &str = "cli";
26
27// DeviceType.LinuxDesktop, as per Bitwarden API device types.
28const DEVICE_TYPE: u8 = 8;
29
30#[derive(Debug)]
31pub struct Client {
32    base_url: String,
33    identity_url: String,
34    ui_url: String,
35    client_cert_path: Option<std::path::PathBuf>,
36}
37
38impl Client {
39    pub fn new(
40        base_url: &str,
41        identity_url: &str,
42        ui_url: &str,
43        client_cert_path: Option<&std::path::Path>,
44    ) -> Self {
45        Self {
46            base_url: base_url.to_string(),
47            identity_url: identity_url.to_string(),
48            ui_url: ui_url.to_string(),
49            client_cert_path: client_cert_path
50                .map(std::path::Path::to_path_buf),
51        }
52    }
53
54    async fn reqwest_client(&self) -> Result<reqwest::Client> {
55        let mut default_headers = reqwest::header::HeaderMap::new();
56        default_headers.insert(
57            "Bitwarden-Client-Name",
58            reqwest::header::HeaderValue::from_static(BITWARDEN_CLIENT),
59        );
60        default_headers.insert(
61            "Bitwarden-Client-Version",
62            reqwest::header::HeaderValue::from_static(env!(
63                "CARGO_PKG_VERSION"
64            )),
65        );
66        default_headers.append(
67            "Device-Type",
68            // unwrap is safe here because DEVICE_TYPE is a number and digits
69            // are valid ASCII
70            reqwest::header::HeaderValue::from_str(&DEVICE_TYPE.to_string())
71                .unwrap(),
72        );
73        let user_agent = format!(
74            "{}/{}",
75            env!("CARGO_PKG_NAME"),
76            env!("CARGO_PKG_VERSION")
77        );
78        if let Some(client_cert_path) = self.client_cert_path.as_ref() {
79            let mut buf = Vec::new();
80            let mut f = tokio::fs::File::open(client_cert_path)
81                .await
82                .map_err(|e| Error::LoadClientCert {
83                    source: e,
84                    file: client_cert_path.clone(),
85                })?;
86            f.read_to_end(&mut buf).await.map_err(|e| {
87                Error::LoadClientCert {
88                    source: e,
89                    file: client_cert_path.clone(),
90                }
91            })?;
92            let pem = reqwest::Identity::from_pem(&buf)
93                .map_err(|e| Error::CreateReqwestClient { source: e })?;
94            Ok(reqwest::Client::builder()
95                .user_agent(user_agent)
96                .identity(pem)
97                .default_headers(default_headers)
98                .build()
99                .map_err(|e| Error::CreateReqwestClient { source: e })?)
100        } else {
101            Ok(reqwest::Client::builder()
102                .user_agent(user_agent)
103                .default_headers(default_headers)
104                .build()
105                .map_err(|e| Error::CreateReqwestClient { source: e })?)
106        }
107    }
108
109    pub async fn prelogin(
110        &self,
111        email: &str,
112    ) -> Result<(KdfType, u32, Option<u32>, Option<u32>)> {
113        let prelogin = PreloginReq {
114            email: email.to_string(),
115        };
116        let client = self.reqwest_client().await?;
117        let res = client
118            .post(self.identity_url("/accounts/prelogin"))
119            .json(&prelogin)
120            .send()
121            .await
122            .map_err(|source| Error::Reqwest { source })?;
123        let prelogin_res: PreloginRes = res.json_with_path().await?;
124        Ok((
125            prelogin_res.kdf,
126            prelogin_res.kdf_iterations,
127            prelogin_res.kdf_memory,
128            prelogin_res.kdf_parallelism,
129        ))
130    }
131
132    pub async fn register(
133        &self,
134        email: &str,
135        device_id: &str,
136        apikey: &crate::locked::ApiKey,
137    ) -> Result<()> {
138        let connect_req = ConnectTokenReq {
139            auth: ConnectTokenAuth::ClientCredentials(
140                ConnectTokenClientCredentials {
141                    username: email.to_string(),
142                    client_secret: String::from_utf8(
143                        apikey.client_secret().to_vec(),
144                    )
145                    .unwrap(),
146                },
147            ),
148            grant_type: "client_credentials".to_string(),
149            scope: "api".to_string(),
150            // XXX unwraps here are not necessarily safe
151            client_id: String::from_utf8(apikey.client_id().to_vec())
152                .unwrap(),
153            device_type: u32::from(DEVICE_TYPE),
154            device_identifier: device_id.to_string(),
155            device_name: "bwx".to_string(),
156            device_push_token: String::new(),
157            two_factor_token: None,
158            two_factor_provider: None,
159        };
160        let client = self.reqwest_client().await?;
161        let res = client
162            .post(self.identity_url("/connect/token"))
163            .form(&connect_req)
164            .send()
165            .await
166            .map_err(|source| Error::Reqwest { source })?;
167        if res.status() == reqwest::StatusCode::OK {
168            Ok(())
169        } else {
170            let code = res.status().as_u16();
171            match res.text().await {
172                Ok(body) => match body.clone().json_with_path() {
173                    Ok(json) => Err(classify_login_error(&json, code)),
174                    Err(e) => {
175                        log::warn!("{e}: {body}");
176                        Err(Error::RequestFailed { status: code })
177                    }
178                },
179                Err(e) => {
180                    log::warn!("failed to read response body: {e}");
181                    Err(Error::RequestFailed { status: code })
182                }
183            }
184        }
185    }
186
187    pub async fn login(
188        &self,
189        email: &str,
190        sso_id: Option<&str>,
191        device_id: &str,
192        password_hash: &crate::locked::PasswordHash,
193        two_factor_token: Option<&str>,
194        two_factor_provider: Option<TwoFactorProviderType>,
195    ) -> Result<(String, String, String)> {
196        let connect_req = match sso_id {
197            Some(sso_id) => {
198                let (sso_code, sso_code_verifier, callback_url) =
199                    self.obtain_sso_code(sso_id).await?;
200
201                ConnectTokenReq {
202                    auth: ConnectTokenAuth::AuthCode(ConnectTokenAuthCode {
203                        code: sso_code,
204                        code_verifier: sso_code_verifier,
205                        redirect_uri: callback_url,
206                    }),
207                    grant_type: "authorization_code".to_string(),
208                    scope: "api offline_access".to_string(),
209                    client_id: "cli".to_string(),
210                    device_type: u32::from(DEVICE_TYPE),
211                    device_identifier: device_id.to_string(),
212                    device_name: "bwx".to_string(),
213                    device_push_token: String::new(),
214                    two_factor_token: two_factor_token
215                        .map(std::string::ToString::to_string),
216                    two_factor_provider: two_factor_provider
217                        .map(|ty| ty as u32),
218                }
219            }
220            None => ConnectTokenReq {
221                auth: ConnectTokenAuth::Password(ConnectTokenPassword {
222                    username: email.to_string(),
223                    password: crate::base64::encode(password_hash.hash()),
224                }),
225
226                grant_type: "password".to_string(),
227                scope: "api offline_access".to_string(),
228                client_id: "cli".to_string(),
229                device_type: 8,
230                device_identifier: device_id.to_string(),
231                device_name: "bwx".to_string(),
232                device_push_token: String::new(),
233                two_factor_token: two_factor_token
234                    .map(std::string::ToString::to_string),
235                two_factor_provider: two_factor_provider.map(|ty| ty as u32),
236            },
237        };
238
239        let client = self.reqwest_client().await?;
240        let res = client
241            .post(self.identity_url("/connect/token"))
242            .form(&connect_req)
243            .header(
244                "auth-email",
245                crate::base64::encode_url_safe_no_pad(email),
246            )
247            .send()
248            .await
249            .map_err(|source| Error::Reqwest { source })?;
250
251        if res.status() == reqwest::StatusCode::OK {
252            let connect_res: ConnectTokenRes = res.json_with_path().await?;
253            Ok((
254                connect_res.access_token,
255                connect_res.refresh_token,
256                connect_res.key,
257            ))
258        } else {
259            let code = res.status().as_u16();
260            match res.text().await {
261                Ok(body) => match body.clone().json_with_path() {
262                    Ok(json) => {
263                        let json: ConnectErrorRes = json;
264                        Err(classify_login_error(&json, code))
265                    }
266                    Err(e) => {
267                        log::warn!("{e}: {body}");
268                        Err(Error::RequestFailed { status: code })
269                    }
270                },
271                Err(e) => {
272                    log::warn!("failed to read response body: {e}");
273                    Err(Error::RequestFailed { status: code })
274                }
275            }
276        }
277    }
278
279    pub async fn send_email_login(
280        &self,
281        email: &str,
282        device_id: &str,
283        sso_email_2fa_session_token: &str,
284    ) -> Result<()> {
285        let send_email_login_req = SendEmailLoginReq {
286            email: email.to_string(),
287            device_identifier: device_id.to_string(),
288            sso_email_2fa_session_token: sso_email_2fa_session_token
289                .to_string(),
290        };
291
292        let client = self.reqwest_client().await?;
293        let res = client
294            .post(self.api_url("/two-factor/send-email-login"))
295            .json(&send_email_login_req)
296            .header(
297                "auth-email",
298                crate::base64::encode_url_safe_no_pad(email),
299            )
300            .send()
301            .await
302            .map_err(|source| Error::Reqwest { source })?;
303
304        if res.status() == reqwest::StatusCode::OK {
305            Ok(())
306        } else {
307            let code = res.status().as_u16();
308            log::warn!("{code}: {:?}", res.text().await);
309            Err(Error::RequestFailed { status: code })
310        }
311    }
312
313    async fn obtain_sso_code(
314        &self,
315        sso_id: &str,
316    ) -> Result<(String, String, String)> {
317        let state =
318            rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
319        let sso_code_verifier =
320            rand::distr::Alphanumeric.sample_string(&mut rand::rng(), 64);
321
322        let mut hasher = sha2::Sha256::new();
323        hasher.update(sso_code_verifier.clone());
324        let code_challenge =
325            crate::base64::encode_url_safe_no_pad(hasher.finalize());
326
327        let port = find_free_port(8065, 8070).await?;
328
329        let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
330            .await
331            .map_err(|e| Error::CreateSSOCallbackServer { err: e })?;
332
333        let callback_server =
334            start_sso_callback_server(listener, state.as_str());
335
336        let callback_url =
337            "http://localhost:".to_string() + port.to_string().as_str();
338
339        let url = self.ui_url.clone()
340            + "/#/sso?clientId="
341            + "cli"
342            + "&redirectUri="
343            + urlencoding::encode(callback_url.as_str())
344                .into_owned()
345                .as_str()
346            + "&state="
347            + state.as_str()
348            + "&codeChallenge="
349            + code_challenge.as_str()
350            + "&identifier="
351            + sso_id;
352
353        #[cfg(feature = "sso-browser")]
354        open::that(&url)
355            .map_err(|e| Error::FailedToOpenWebBrowser { err: e })?;
356        #[cfg(not(feature = "sso-browser"))]
357        eprintln!("Open this URL to continue: {url}");
358        // TODO: probably it'd be better to display the URL in the console if the automatic
359        // open operation fails, instead of failing the whole process? E.g. docker container
360        // case
361
362        let sso_code = callback_server.await?;
363
364        Ok((sso_code, sso_code_verifier, callback_url))
365    }
366
367    pub async fn sync(
368        &self,
369        access_token: &str,
370    ) -> Result<(
371        String,
372        String,
373        std::collections::HashMap<String, String>,
374        Vec<crate::db::Entry>,
375    )> {
376        let client = self.reqwest_client().await?;
377        let res = client
378            .get(self.api_url("/sync"))
379            .header("Authorization", format!("Bearer {access_token}"))
380            // This is necessary for vaultwarden to include the ssh keys in the response
381            .header("Bitwarden-Client-Version", "2024.12.0")
382            .send()
383            .await
384            .map_err(|source| Error::Reqwest { source })?;
385        match res.status() {
386            reqwest::StatusCode::OK => {
387                let sync_res: SyncRes = res.json_with_path().await?;
388                let folders = sync_res.folders.clone();
389                let ciphers = sync_res
390                    .ciphers
391                    .iter()
392                    .filter_map(|cipher| cipher.to_entry(&folders))
393                    .collect();
394                let org_keys = sync_res
395                    .profile
396                    .organizations
397                    .iter()
398                    .map(|org| (org.id.clone(), org.key.clone()))
399                    .collect();
400                Ok((
401                    sync_res.profile.key,
402                    sync_res.profile.private_key,
403                    org_keys,
404                    ciphers,
405                ))
406            }
407            reqwest::StatusCode::UNAUTHORIZED => {
408                Err(Error::RequestUnauthorized)
409            }
410            _ => Err(Error::RequestFailed {
411                status: res.status().as_u16(),
412            }),
413        }
414    }
415
416    pub fn add(
417        &self,
418        access_token: &str,
419        name: &str,
420        data: &crate::db::EntryData,
421        notes: Option<&str>,
422        folder_id: Option<&str>,
423    ) -> Result<()> {
424        let mut req = CiphersPostReq {
425            ty: 1,
426            folder_id: folder_id.map(std::string::ToString::to_string),
427            name: name.to_string(),
428            notes: notes.map(std::string::ToString::to_string),
429            login: None,
430            card: None,
431            identity: None,
432            secure_note: None,
433        };
434        match data {
435            crate::db::EntryData::Login {
436                username,
437                password,
438                totp,
439                uris,
440            } => {
441                let uris = if uris.is_empty() {
442                    None
443                } else {
444                    Some(
445                        uris.iter()
446                            .map(|s| CipherLoginUri {
447                                uri: Some(s.uri.clone()),
448                                match_type: s.match_type,
449                            })
450                            .collect(),
451                    )
452                };
453                req.login = Some(CipherLogin {
454                    username: username.clone(),
455                    password: password.clone(),
456                    totp: totp.clone(),
457                    uris,
458                });
459            }
460            crate::db::EntryData::Card {
461                cardholder_name,
462                number,
463                brand,
464                exp_month,
465                exp_year,
466                code,
467            } => {
468                req.card = Some(CipherCard {
469                    cardholder_name: cardholder_name.clone(),
470                    number: number.clone(),
471                    brand: brand.clone(),
472                    exp_month: exp_month.clone(),
473                    exp_year: exp_year.clone(),
474                    code: code.clone(),
475                });
476            }
477            crate::db::EntryData::Identity {
478                title,
479                first_name,
480                middle_name,
481                last_name,
482                address1,
483                address2,
484                address3,
485                city,
486                state,
487                postal_code,
488                country,
489                phone,
490                email,
491                ssn,
492                license_number,
493                passport_number,
494                username,
495            } => {
496                req.identity = Some(CipherIdentity {
497                    title: title.clone(),
498                    first_name: first_name.clone(),
499                    middle_name: middle_name.clone(),
500                    last_name: last_name.clone(),
501                    address1: address1.clone(),
502                    address2: address2.clone(),
503                    address3: address3.clone(),
504                    city: city.clone(),
505                    state: state.clone(),
506                    postal_code: postal_code.clone(),
507                    country: country.clone(),
508                    phone: phone.clone(),
509                    email: email.clone(),
510                    ssn: ssn.clone(),
511                    license_number: license_number.clone(),
512                    passport_number: passport_number.clone(),
513                    username: username.clone(),
514                });
515            }
516            crate::db::EntryData::SecureNote => {
517                req.secure_note = Some(CipherSecureNote {});
518            }
519            crate::db::EntryData::SshKey { .. } => unreachable!(),
520        }
521        let client = reqwest::blocking::Client::new();
522        let res = client
523            .post(self.api_url("/ciphers"))
524            .header("Authorization", format!("Bearer {access_token}"))
525            .json(&req)
526            .send()
527            .map_err(|source| Error::Reqwest { source })?;
528        match res.status() {
529            reqwest::StatusCode::OK => Ok(()),
530            reqwest::StatusCode::UNAUTHORIZED => {
531                Err(Error::RequestUnauthorized)
532            }
533            _ => Err(Error::RequestFailed {
534                status: res.status().as_u16(),
535            }),
536        }
537    }
538
539    pub fn edit(
540        &self,
541        access_token: &str,
542        id: &str,
543        org_id: Option<&str>,
544        name: &str,
545        data: &crate::db::EntryData,
546        fields: &[crate::db::Field],
547        notes: Option<&str>,
548        folder_uuid: Option<&str>,
549        history: &[crate::db::HistoryEntry],
550    ) -> Result<()> {
551        let mut req = CiphersPutReq {
552            ty: match data {
553                crate::db::EntryData::Login { .. } => 1,
554                crate::db::EntryData::SecureNote => 2,
555                crate::db::EntryData::Card { .. } => 3,
556                crate::db::EntryData::Identity { .. } => 4,
557                crate::db::EntryData::SshKey { .. } => unreachable!(),
558            },
559            folder_id: folder_uuid.map(std::string::ToString::to_string),
560            organization_id: org_id.map(std::string::ToString::to_string),
561            name: name.to_string(),
562            notes: notes.map(std::string::ToString::to_string),
563            login: None,
564            card: None,
565            identity: None,
566            secure_note: None,
567            fields: fields
568                .iter()
569                .map(|field| CipherField {
570                    ty: field.ty,
571                    name: field.name.clone(),
572                    value: field.value.clone(),
573                    linked_id: field.linked_id,
574                })
575                .collect(),
576            password_history: history
577                .iter()
578                .map(|entry| CiphersPutReqHistory {
579                    last_used_date: entry.last_used_date.clone(),
580                    password: entry.password.clone(),
581                })
582                .collect(),
583        };
584        match data {
585            crate::db::EntryData::Login {
586                username,
587                password,
588                totp,
589                uris,
590            } => {
591                let uris = if uris.is_empty() {
592                    None
593                } else {
594                    Some(
595                        uris.iter()
596                            .map(|s| CipherLoginUri {
597                                uri: Some(s.uri.clone()),
598                                match_type: s.match_type,
599                            })
600                            .collect(),
601                    )
602                };
603                req.login = Some(CipherLogin {
604                    username: username.clone(),
605                    password: password.clone(),
606                    totp: totp.clone(),
607                    uris,
608                });
609            }
610            crate::db::EntryData::Card {
611                cardholder_name,
612                number,
613                brand,
614                exp_month,
615                exp_year,
616                code,
617            } => {
618                req.card = Some(CipherCard {
619                    cardholder_name: cardholder_name.clone(),
620                    number: number.clone(),
621                    brand: brand.clone(),
622                    exp_month: exp_month.clone(),
623                    exp_year: exp_year.clone(),
624                    code: code.clone(),
625                });
626            }
627            crate::db::EntryData::Identity {
628                title,
629                first_name,
630                middle_name,
631                last_name,
632                address1,
633                address2,
634                address3,
635                city,
636                state,
637                postal_code,
638                country,
639                phone,
640                email,
641                ssn,
642                license_number,
643                passport_number,
644                username,
645            } => {
646                req.identity = Some(CipherIdentity {
647                    title: title.clone(),
648                    first_name: first_name.clone(),
649                    middle_name: middle_name.clone(),
650                    last_name: last_name.clone(),
651                    address1: address1.clone(),
652                    address2: address2.clone(),
653                    address3: address3.clone(),
654                    city: city.clone(),
655                    state: state.clone(),
656                    postal_code: postal_code.clone(),
657                    country: country.clone(),
658                    phone: phone.clone(),
659                    email: email.clone(),
660                    ssn: ssn.clone(),
661                    license_number: license_number.clone(),
662                    passport_number: passport_number.clone(),
663                    username: username.clone(),
664                });
665            }
666            crate::db::EntryData::SecureNote => {
667                req.secure_note = Some(CipherSecureNote {});
668            }
669            crate::db::EntryData::SshKey { .. } => unreachable!(),
670        }
671        let client = reqwest::blocking::Client::new();
672        let res = client
673            .put(self.api_url(&format!("/ciphers/{id}")))
674            .header("Authorization", format!("Bearer {access_token}"))
675            .json(&req)
676            .send()
677            .map_err(|source| Error::Reqwest { source })?;
678        match res.status() {
679            reqwest::StatusCode::OK => Ok(()),
680            reqwest::StatusCode::UNAUTHORIZED => {
681                Err(Error::RequestUnauthorized)
682            }
683            _ => Err(Error::RequestFailed {
684                status: res.status().as_u16(),
685            }),
686        }
687    }
688
689    pub fn remove(&self, access_token: &str, id: &str) -> Result<()> {
690        let client = reqwest::blocking::Client::new();
691        let res = client
692            .delete(self.api_url(&format!("/ciphers/{id}")))
693            .header("Authorization", format!("Bearer {access_token}"))
694            .send()
695            .map_err(|source| Error::Reqwest { source })?;
696        match res.status() {
697            reqwest::StatusCode::OK => Ok(()),
698            reqwest::StatusCode::UNAUTHORIZED => {
699                Err(Error::RequestUnauthorized)
700            }
701            _ => Err(Error::RequestFailed {
702                status: res.status().as_u16(),
703            }),
704        }
705    }
706
707    pub fn folders(
708        &self,
709        access_token: &str,
710    ) -> Result<Vec<(String, String)>> {
711        let client = reqwest::blocking::Client::new();
712        let res = client
713            .get(self.api_url("/folders"))
714            .header("Authorization", format!("Bearer {access_token}"))
715            .send()
716            .map_err(|source| Error::Reqwest { source })?;
717        match res.status() {
718            reqwest::StatusCode::OK => {
719                let folders_res: FoldersRes = res.json_with_path()?;
720                Ok(folders_res
721                    .data
722                    .iter()
723                    .map(|folder| (folder.id.clone(), folder.name.clone()))
724                    .collect())
725            }
726            reqwest::StatusCode::UNAUTHORIZED => {
727                Err(Error::RequestUnauthorized)
728            }
729            _ => Err(Error::RequestFailed {
730                status: res.status().as_u16(),
731            }),
732        }
733    }
734
735    pub fn create_folder(
736        &self,
737        access_token: &str,
738        name: &str,
739    ) -> Result<String> {
740        let req = FoldersPostReq {
741            name: name.to_string(),
742        };
743        let client = reqwest::blocking::Client::new();
744        let res = client
745            .post(self.api_url("/folders"))
746            .header("Authorization", format!("Bearer {access_token}"))
747            .json(&req)
748            .send()
749            .map_err(|source| Error::Reqwest { source })?;
750        match res.status() {
751            reqwest::StatusCode::OK => {
752                let folders_res: FoldersResData = res.json_with_path()?;
753                Ok(folders_res.id)
754            }
755            reqwest::StatusCode::UNAUTHORIZED => {
756                Err(Error::RequestUnauthorized)
757            }
758            _ => Err(Error::RequestFailed {
759                status: res.status().as_u16(),
760            }),
761        }
762    }
763
764    pub fn exchange_refresh_token(
765        &self,
766        refresh_token: &str,
767    ) -> Result<String> {
768        let connect_req = ConnectRefreshTokenReq {
769            grant_type: "refresh_token".to_string(),
770            client_id: "cli".to_string(),
771            refresh_token: refresh_token.to_string(),
772        };
773        let client = reqwest::blocking::Client::new();
774        let res = client
775            .post(self.identity_url("/connect/token"))
776            .form(&connect_req)
777            .send()
778            .map_err(|source| Error::Reqwest { source })?;
779        let connect_res: ConnectRefreshTokenRes = res.json_with_path()?;
780        Ok(connect_res.access_token)
781    }
782
783    pub async fn exchange_refresh_token_async(
784        &self,
785        refresh_token: &str,
786    ) -> Result<String> {
787        let connect_req = ConnectRefreshTokenReq {
788            grant_type: "refresh_token".to_string(),
789            client_id: "cli".to_string(),
790            refresh_token: refresh_token.to_string(),
791        };
792        let client = self.reqwest_client().await?;
793        let res = client
794            .post(self.identity_url("/connect/token"))
795            .form(&connect_req)
796            .send()
797            .await
798            .map_err(|source| Error::Reqwest { source })?;
799        let connect_res: ConnectRefreshTokenRes =
800            res.json_with_path().await?;
801        Ok(connect_res.access_token)
802    }
803
804    fn api_url(&self, path: &str) -> String {
805        format!("{}{}", self.base_url, path)
806    }
807
808    fn identity_url(&self, path: &str) -> String {
809        format!("{}{}", self.identity_url, path)
810    }
811}