1use base64::Engine;
11use base64::engine::general_purpose::URL_SAFE_NO_PAD;
12use chrono::{DateTime, Utc};
13use rand::RngCore;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use std::collections::HashMap;
17use std::fs;
18use std::io::{self, Write};
19use std::path::PathBuf;
20
21#[derive(Debug, thiserror::Error)]
27#[non_exhaustive]
28pub enum OAuthError {
29 #[error("IO error: {0}")]
30 Io(#[from] io::Error),
32
33 #[error("HTTP request failed: {0}")]
34 Http(#[from] reqwest::Error),
36
37 #[error("JSON error: {0}")]
38 Json(#[from] serde_json::Error),
40
41 #[error("Token expired and no refresh_token available")]
42 NoRefreshToken,
44
45 #[error("Token refresh failed: {0}")]
46 RefreshFailed(String),
48
49 #[error("Device flow polling timed out after {0}s")]
50 DeviceFlowTimeout(u64),
52
53 #[error("Device flow authorization pending")]
54 DeviceFlowPending,
56
57 #[error("Device flow rejected by user")]
58 DeviceFlowRejected,
60
61 #[error("Missing environment variable: {0}")]
62 MissingEnv(String),
64
65 #[error("Invalid state: {0}")]
66 InvalidState(String),
68
69 #[error("Invalid authorization endpoint URL: {0}")]
71 InvalidAuthorizationEndpoint(#[from] url::ParseError),
72}
73
74type Result<T> = std::result::Result<T, OAuthError>;
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct TokenBundle {
83 pub access_token: String,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub refresh_token: Option<String>,
88 #[serde(default = "default_token_type")]
90 pub token_type: String,
91 pub obtained_at: DateTime<Utc>,
93 #[serde(default)]
95 pub expires_in: u64,
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub scope: Option<String>,
99}
100
101fn default_token_type() -> String {
102 "Bearer".to_string()
103}
104
105impl TokenBundle {
106 pub fn is_expired(&self) -> bool {
108 if self.expires_in == 0 {
109 return false; }
111 let expires_at = self.obtained_at + chrono::Duration::seconds(self.expires_in as i64);
112 Utc::now() >= expires_at - chrono::Duration::seconds(60)
113 }
114}
115
116#[derive(Debug, Default, Serialize, Deserialize)]
118pub struct AuthStore {
119 #[serde(flatten)]
121 pub tokens: HashMap<String, TokenBundle>,
122}
123
124pub fn default_auth_path() -> Result<PathBuf> {
134 let path = crate::product_env::auth_path().ok_or_else(|| {
135 OAuthError::InvalidState(
136 "Cannot determine product home directory (neither OXICODE_HOME nor HOME is set)".into(),
137 )
138 })?;
139 if let Some(parent) = path.parent()
140 && !parent.exists()
141 {
142 fs::create_dir_all(parent)?;
143 }
144 Ok(path)
145}
146
147pub fn load_auth_store() -> Result<AuthStore> {
149 let path = default_auth_path()?;
150 if !path.exists() {
151 return Ok(AuthStore::default());
152 }
153 let data = fs::read_to_string(&path)?;
154 let store: AuthStore = serde_json::from_str(&data)?;
155 Ok(store)
156}
157
158pub fn save_auth_store(store: &AuthStore) -> Result<()> {
160 let path = default_auth_path()?;
161 let json = serde_json::to_string_pretty(store)?;
162
163 let tmp_path = path.with_extension("json.tmp");
165 {
166 let mut file = fs::File::create(&tmp_path)?;
167 file.write_all(json.as_bytes())?;
168 file.flush()?;
169 #[cfg(unix)]
171 {
172 use std::os::unix::fs::PermissionsExt;
173 let perms = fs::Permissions::from_mode(0o600);
174 fs::set_permissions(&tmp_path, perms)?;
175 }
176 }
177 fs::rename(&tmp_path, &path)?;
178 Ok(())
179}
180
181pub fn load_token(provider: &str) -> Result<Option<TokenBundle>> {
183 let store = load_auth_store()?;
184 Ok(store.tokens.get(provider).cloned())
185}
186
187pub fn save_token(provider: &str, token: &TokenBundle) -> Result<()> {
189 let mut store = load_auth_store()?;
190 store.tokens.insert(provider.to_string(), token.clone());
191 save_auth_store(&store)
192}
193
194pub fn remove_token(provider: &str) -> Result<()> {
196 let mut store = load_auth_store()?;
197 store.tokens.remove(provider);
198 save_auth_store(&store)
199}
200
201pub fn generate_code_verifier() -> String {
207 let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes);
209 URL_SAFE_NO_PAD.encode(bytes)
210}
211
212pub fn derive_code_challenge(verifier: &str) -> String {
214 let mut hasher = Sha256::new();
215 hasher.update(verifier.as_bytes());
216 let hash = hasher.finalize();
217 URL_SAFE_NO_PAD.encode(hash)
218}
219
220#[derive(Debug, Clone)]
226pub struct OAuthConfig {
227 pub authorization_endpoint: String,
229 pub token_endpoint: String,
231 pub client_id: String,
233 pub redirect_uri: String,
235 pub scopes: String,
237}
238
239pub fn anthropic_config() -> Result<OAuthConfig> {
241 let client_id = std::env::var("ANTHROPIC_OAUTH_CLIENT_ID")
242 .map_err(|_| OAuthError::MissingEnv("ANTHROPIC_OAUTH_CLIENT_ID".into()))?;
243 Ok(OAuthConfig {
244 authorization_endpoint: "https://console.anthropic.com/api/oauth".into(),
245 token_endpoint: "https://console.anthropic.com/api/oauth/token".into(),
246 client_id,
247 redirect_uri: "http://localhost:8787/callback".into(),
248 scopes: "org.api.read org.api.write".into(),
249 })
250}
251
252pub fn openai_codex_config() -> Result<OAuthConfig> {
254 let client_id = std::env::var("OPENAI_OAUTH_CLIENT_ID")
255 .map_err(|_| OAuthError::MissingEnv("OPENAI_OAUTH_CLIENT_ID".into()))?;
256 Ok(OAuthConfig {
257 authorization_endpoint: "https://auth.openai.com/authorize".into(),
258 token_endpoint: "https://auth.openai.com/oauth/token".into(),
259 client_id,
260 redirect_uri: "http://localhost:8787/callback".into(),
261 scopes: "".into(),
262 })
263}
264
265#[derive(Debug, Clone)]
271pub struct PkceState {
272 pub code_verifier: String,
274 pub code_challenge: String,
276 pub authorization_url: String,
278 pub state: String,
280}
281
282pub fn build_authorization_url_result(config: &OAuthConfig) -> Result<PkceState> {
290 let code_verifier = generate_code_verifier();
291 let code_challenge = derive_code_challenge(&code_verifier);
292 let state = generate_state_token();
293
294 let mut url = url::Url::parse(&config.authorization_endpoint)?;
295 url.query_pairs_mut()
296 .append_pair("response_type", "code")
297 .append_pair("client_id", &config.client_id)
298 .append_pair("redirect_uri", &config.redirect_uri)
299 .append_pair("code_challenge", &code_challenge)
300 .append_pair("code_challenge_method", "S256")
301 .append_pair("state", &state);
302
303 if !config.scopes.is_empty() {
304 url.query_pairs_mut().append_pair("scope", &config.scopes);
305 }
306
307 Ok(PkceState {
308 code_verifier,
309 code_challenge,
310 authorization_url: url.to_string(),
311 state,
312 })
313}
314
315#[deprecated(
324 since = "0.64.0",
325 note = "use build_authorization_url_result instead; will be removed in 0.66.0"
326)]
327pub fn build_authorization_url(config: &OAuthConfig) -> PkceState {
328 #[allow(clippy::expect_used)]
331 build_authorization_url_result(config).expect("invalid authorization endpoint")
332}
333
334fn generate_state_token() -> String {
336 let mut bytes = [0u8; 16];
337 rand::rng().fill_bytes(&mut bytes);
338 URL_SAFE_NO_PAD.encode(bytes)
339}
340
341pub async fn exchange_code(
347 client: &reqwest::Client,
348 config: &OAuthConfig,
349 pkce: &PkceState,
350 code: &str,
351) -> Result<TokenBundle> {
352 #[derive(Serialize)]
353 struct TokenRequest {
354 grant_type: String,
355 code: String,
356 redirect_uri: String,
357 client_id: String,
358 code_verifier: String,
359 }
360
361 #[derive(Deserialize)]
362 struct TokenResponse {
363 access_token: String,
364 #[serde(default)]
365 refresh_token: Option<String>,
366 #[serde(default = "default_token_type")]
367 token_type: String,
368 #[serde(default)]
369 expires_in: u64,
370 #[serde(default)]
371 scope: Option<String>,
372 }
373
374 let body = TokenRequest {
375 grant_type: "authorization_code".into(),
376 code: code.into(),
377 redirect_uri: config.redirect_uri.clone(),
378 client_id: config.client_id.clone(),
379 code_verifier: pkce.code_verifier.clone(),
380 };
381
382 let resp = client
383 .post(&config.token_endpoint)
384 .header("content-type", "application/json")
385 .header("accept", "application/json")
386 .json(&body)
387 .send()
388 .await?;
389
390 let status = resp.status();
391 if !status.is_success() {
392 let text = resp.text().await.unwrap_or_default();
393 return Err(OAuthError::RefreshFailed(format!(
394 "Token exchange failed ({status}): {text}"
395 )));
396 }
397
398 let tr: TokenResponse = resp.json().await?;
399 Ok(TokenBundle {
400 access_token: tr.access_token,
401 refresh_token: tr.refresh_token,
402 token_type: tr.token_type,
403 obtained_at: Utc::now(),
404 expires_in: tr.expires_in,
405 scope: tr.scope,
406 })
407}
408
409pub async fn refresh_token(
415 client: &reqwest::Client,
416 config: &OAuthConfig,
417 bundle: &TokenBundle,
418) -> Result<TokenBundle> {
419 let refresh = bundle
420 .refresh_token
421 .as_ref()
422 .ok_or(OAuthError::NoRefreshToken)?;
423
424 #[derive(Serialize)]
425 struct RefreshRequest {
426 grant_type: String,
427 refresh_token: String,
428 client_id: String,
429 }
430
431 #[derive(Deserialize)]
432 struct TokenResponse {
433 access_token: String,
434 #[serde(default)]
435 refresh_token: Option<String>,
436 #[serde(default = "default_token_type")]
437 token_type: String,
438 #[serde(default)]
439 expires_in: u64,
440 #[serde(default)]
441 scope: Option<String>,
442 }
443
444 let body = RefreshRequest {
445 grant_type: "refresh_token".into(),
446 refresh_token: refresh.clone(),
447 client_id: config.client_id.clone(),
448 };
449
450 let resp = client
451 .post(&config.token_endpoint)
452 .header("content-type", "application/json")
453 .header("accept", "application/json")
454 .json(&body)
455 .send()
456 .await?;
457
458 let status = resp.status();
459 if !status.is_success() {
460 let text = resp.text().await.unwrap_or_default();
461 return Err(OAuthError::RefreshFailed(format!(
462 "Refresh failed ({status}): {text}"
463 )));
464 }
465
466 let tr: TokenResponse = resp.json().await?;
467 Ok(TokenBundle {
468 access_token: tr.access_token,
469 refresh_token: tr.refresh_token.or_else(|| Some(refresh.clone())),
470 token_type: tr.token_type,
471 obtained_at: Utc::now(),
472 expires_in: tr.expires_in,
473 scope: tr.scope,
474 })
475}
476
477pub async fn ensure_valid_token(
480 client: &reqwest::Client,
481 config: &OAuthConfig,
482 provider_key: &str,
483) -> Result<TokenBundle> {
484 let bundle = load_token(provider_key)?.ok_or(OAuthError::InvalidState(format!(
485 "No token stored for {provider_key}"
486 )))?;
487
488 if !bundle.is_expired() {
489 return Ok(bundle);
490 }
491
492 let refreshed = refresh_token(client, config, &bundle).await?;
493 save_token(provider_key, &refreshed)?;
494 Ok(refreshed)
495}
496
497#[derive(Debug, Deserialize)]
503pub struct DeviceCodeResponse {
504 pub device_code: String,
506 pub user_code: String,
508 pub verification_uri: String,
510 #[serde(default)]
512 pub verification_uri_complete: Option<String>,
513 pub interval: u64,
515 pub expires_in: u64,
517}
518
519#[derive(Debug)]
521pub enum DeviceFlowResult {
522 Success(TokenBundle),
524 Pending,
526 Rejected,
528 Timeout(u64),
530}
531
532pub async fn github_request_device_code(
534 client: &reqwest::Client,
535 client_id: &str,
536 scope: &str,
537) -> Result<DeviceCodeResponse> {
538 #[derive(Serialize)]
539 struct Body {
540 client_id: String,
541 scope: String,
542 }
543
544 let resp = client
545 .post("https://github.com/login/device/code")
546 .header("accept", "application/json")
547 .json(&Body {
548 client_id: client_id.into(),
549 scope: scope.into(),
550 })
551 .send()
552 .await?;
553
554 let status = resp.status();
555 if !status.is_success() {
556 let text = resp.text().await.unwrap_or_default();
557 return Err(OAuthError::RefreshFailed(format!(
558 "Device code request failed ({status}): {text}"
559 )));
560 }
561
562 Ok(resp.json().await?)
563}
564
565pub async fn github_poll_for_token(
570 client: &reqwest::Client,
571 client_id: &str,
572 device_code: &str,
573 timeout_secs: u64,
574) -> Result<DeviceFlowResult> {
575 #[derive(Serialize)]
576 struct Body {
577 client_id: String,
578 device_code: String,
579 grant_type: String,
580 }
581
582 #[derive(Deserialize)]
583 struct TokenResponse {
584 #[serde(default)]
585 access_token: Option<String>,
586 #[serde(default)]
587 error: Option<String>,
588 #[serde(default)]
589 token_type: Option<String>,
590 #[serde(default)]
591 scope: Option<String>,
592 }
593
594 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
595
596 loop {
597 if std::time::Instant::now() > deadline {
598 return Ok(DeviceFlowResult::Timeout(timeout_secs));
599 }
600
601 let resp = client
602 .post("https://github.com/login/oauth/access_token")
603 .header("accept", "application/json")
604 .json(&Body {
605 client_id: client_id.into(),
606 device_code: device_code.into(),
607 grant_type: "urn:ietf:params:oauth:grant-type:device_code".into(),
608 })
609 .send()
610 .await?;
611
612 let tr: TokenResponse = resp.json().await?;
613
614 if let Some(token) = tr.access_token {
615 return Ok(DeviceFlowResult::Success(TokenBundle {
616 access_token: token,
617 refresh_token: None,
618 token_type: tr.token_type.unwrap_or_else(|| "Bearer".into()),
619 obtained_at: Utc::now(),
620 expires_in: 0, scope: tr.scope,
622 }));
623 }
624
625 match tr.error.as_deref() {
626 Some("authorization_pending") => {
627 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
628 continue;
629 }
630 Some("slow_down") => {
631 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
632 continue;
633 }
634 Some("expired_token") => return Ok(DeviceFlowResult::Rejected),
635 Some("access_denied") => return Ok(DeviceFlowResult::Rejected),
636 Some(other) => {
637 return Err(OAuthError::RefreshFailed(format!(
638 "Device flow error: {other}"
639 )));
640 }
641 None => {
642 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
644 continue;
645 }
646 }
647 }
648}
649
650pub async fn github_device_flow(
652 client: &reqwest::Client,
653 client_id: &str,
654 scope: &str,
655 timeout_secs: u64,
656) -> Result<TokenBundle> {
657 let dc = github_request_device_code(client, client_id, scope).await?;
658
659 println!();
660 println!("=== GitHub Device Authorization ===");
661 println!(" 1. Open: {}", dc.verification_uri);
662 println!(" 2. Enter code: {}", dc.user_code);
663 if let Some(ref url) = dc.verification_uri_complete {
664 println!(" Or visit: {url}");
665 }
666 println!();
667
668 let result = github_poll_for_token(client, client_id, &dc.device_code, timeout_secs).await?;
669
670 match result {
671 DeviceFlowResult::Success(token) => {
672 save_token("github", &token)?;
673 println!("✓ GitHub authentication successful.");
674 Ok(token)
675 }
676 DeviceFlowResult::Pending => Err(OAuthError::DeviceFlowPending),
677 DeviceFlowResult::Rejected => Err(OAuthError::DeviceFlowRejected),
678 DeviceFlowResult::Timeout(s) => Err(OAuthError::DeviceFlowTimeout(s)),
679 }
680}
681
682#[cfg(test)]
687mod tests {
688 use super::*;
689 use tempfile::TempDir;
690
691 #[test]
694 fn test_code_verifier_length() {
695 let v = generate_code_verifier();
696 assert!((43..=128).contains(&v.len()), "verifier length {}", v.len());
697 }
698
699 #[test]
700 fn test_code_verifier_is_base64url() {
701 let v = generate_code_verifier();
702 assert!(
704 v.chars()
705 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
706 );
707 }
708
709 #[test]
710 fn test_code_verifier_uniqueness() {
711 let a = generate_code_verifier();
712 let b = generate_code_verifier();
713 assert_ne!(a, b, "two verifiers should differ");
714 }
715
716 #[test]
717 fn test_code_challenge_deterministic() {
718 let v = generate_code_verifier();
719 let c1 = derive_code_challenge(&v);
720 let c2 = derive_code_challenge(&v);
721 assert_eq!(c1, c2);
722 }
723
724 #[test]
725 fn test_code_challenge_differs_from_verifier() {
726 let v = generate_code_verifier();
727 let c = derive_code_challenge(&v);
728 assert_ne!(v, c);
729 }
730
731 #[test]
732 fn test_code_challenge_is_base64url() {
733 let v = generate_code_verifier();
734 let c = derive_code_challenge(&v);
735 assert!(
736 c.chars()
737 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
738 );
739 }
740
741 #[test]
742 fn test_known_pkce_vector() {
743 let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
745 let challenge = derive_code_challenge(verifier);
746 assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
747 }
748
749 #[test]
752 fn test_token_bundle_not_expired_when_no_expiry() {
753 let bundle = TokenBundle {
754 access_token: "abc".into(),
755 refresh_token: None,
756 token_type: "Bearer".into(),
757 obtained_at: Utc::now(),
758 expires_in: 0,
759 scope: None,
760 };
761 assert!(!bundle.is_expired());
762 }
763
764 #[test]
765 fn test_token_bundle_expired() {
766 let bundle = TokenBundle {
767 access_token: "abc".into(),
768 refresh_token: None,
769 token_type: "Bearer".into(),
770 obtained_at: Utc::now() - chrono::Duration::seconds(3600),
771 expires_in: 1800, scope: None,
773 };
774 assert!(bundle.is_expired());
775 }
776
777 #[test]
778 fn test_token_bundle_not_yet_expired() {
779 let bundle = TokenBundle {
780 access_token: "abc".into(),
781 refresh_token: None,
782 token_type: "Bearer".into(),
783 obtained_at: Utc::now(),
784 expires_in: 3600, scope: None,
786 };
787 assert!(!bundle.is_expired());
788 }
789
790 fn setup_temp_store() -> TempDir {
793 tempfile::tempdir().expect("tempdir")
794 }
795
796 fn with_temp_auth_store<F>(f: F)
797 where
798 F: FnOnce(&PathBuf),
799 {
800 let dir = setup_temp_store();
801 let path = dir.path().join("auth.json");
802 let mut store = AuthStore::default();
805 store.tokens.insert(
806 "test-provider".into(),
807 TokenBundle {
808 access_token: "tok_abc123".into(),
809 refresh_token: Some("ref_xyz".into()),
810 token_type: "Bearer".into(),
811 obtained_at: Utc::now(),
812 expires_in: 3600,
813 scope: Some("read write".into()),
814 },
815 );
816 let json = serde_json::to_string_pretty(&store).unwrap();
817 fs::write(&path, &json).unwrap();
818
819 f(&path);
820
821 let loaded: AuthStore = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
823 assert_eq!(loaded.tokens["test-provider"].access_token, "tok_abc123");
824 assert_eq!(
825 loaded.tokens["test-provider"].refresh_token.as_deref(),
826 Some("ref_xyz")
827 );
828 }
829
830 #[test]
831 fn test_auth_store_round_trip() {
832 with_temp_auth_store(|_| {});
833 }
834
835 #[test]
836 fn test_auth_store_missing_file() {
837 let dir = tempfile::tempdir().unwrap();
838 let path = dir.path().join("nonexistent.json");
839 assert!(!path.exists());
840 let result = fs::read_to_string(&path);
842 assert!(result.is_err());
843 }
844
845 #[test]
848 fn test_build_authorization_url_contains_pkce_params() {
849 let config = OAuthConfig {
850 authorization_endpoint: "https://example.com/authorize".into(),
851 token_endpoint: "https://example.com/token".into(),
852 client_id: "my-client".into(),
853 redirect_uri: "http://localhost:8787/callback".into(),
854 scopes: "read write".into(),
855 };
856 let pkce = build_authorization_url_result(&config).expect("valid config should parse");
857
858 assert!(pkce.authorization_url.contains("code_challenge="));
859 assert!(
860 pkce.authorization_url
861 .contains("code_challenge_method=S256")
862 );
863 assert!(pkce.authorization_url.contains("client_id=my-client"));
864 assert!(pkce.authorization_url.contains("response_type=code"));
865 assert!(pkce.authorization_url.contains("state="));
866 assert!(pkce.authorization_url.contains("scope="));
867 assert_eq!(pkce.code_verifier.len(), 43);
868 }
869
870 #[test]
871 fn test_build_authorization_url_result_rejects_malformed_endpoint() {
872 let config = OAuthConfig {
873 authorization_endpoint: "http://[".into(),
874 token_endpoint: "https://example.com/token".into(),
875 client_id: "my-client".into(),
876 redirect_uri: "http://localhost:8787/callback".into(),
877 scopes: "".into(),
878 };
879 let err = build_authorization_url_result(&config)
880 .expect_err("malformed endpoint must not produce a URL");
881 assert!(
882 matches!(err, OAuthError::InvalidAuthorizationEndpoint(_)),
883 "expected InvalidAuthorizationEndpoint, got {err:?}"
884 );
885 }
886
887 #[test]
888 fn test_state_token_length() {
889 let state = generate_state_token();
890 assert!(state.len() >= 16, "state token should be at least 16 chars");
891 }
892
893 #[test]
896 fn test_token_bundle_serialize_deserialize() {
897 let bundle = TokenBundle {
898 access_token: "at_123".into(),
899 refresh_token: None,
900 token_type: "Bearer".into(),
901 obtained_at: "2025-01-01T00:00:00Z".parse().unwrap(),
902 expires_in: 3600,
903 scope: Some("org.api.read".into()),
904 };
905 let json = serde_json::to_string(&bundle).unwrap();
906 let back: TokenBundle = serde_json::from_str(&json).unwrap();
907 assert_eq!(back.access_token, "at_123");
908 assert!(back.refresh_token.is_none());
909 assert_eq!(back.expires_in, 3600);
910 }
911
912 #[test]
913 fn test_auth_store_multiple_providers() {
914 let mut store = AuthStore::default();
915 for name in &["anthropic", "openai", "github"] {
916 store.tokens.insert(
917 (*name).into(),
918 TokenBundle {
919 access_token: format!("tok_{name}"),
920 refresh_token: None,
921 token_type: "Bearer".into(),
922 obtained_at: Utc::now(),
923 expires_in: 0,
924 scope: None,
925 },
926 );
927 }
928 let json = serde_json::to_string(&store).unwrap();
929 let back: AuthStore = serde_json::from_str(&json).unwrap();
930 assert_eq!(back.tokens.len(), 3);
931 assert_eq!(back.tokens["openai"].access_token, "tok_openai");
932 }
933}