1use std::time::{Duration, Instant};
23
24use serde::Deserialize;
25
26use crate::error::{Result, ToriiError};
27
28struct DeviceFlowProvider {
32 device_authz_url: &'static str,
34 token_url: &'static str,
36 scopes: &'static str,
38 client_id_env: &'static str,
42 bundled_client_id: Option<&'static str>,
46}
47
48fn device_flow_provider(provider: &str) -> Option<DeviceFlowProvider> {
49 match provider {
56 "github" => Some(DeviceFlowProvider {
57 device_authz_url: "https://github.com/login/device/code",
58 token_url: "https://github.com/login/oauth/access_token",
59 scopes: "repo read:org workflow",
60 client_id_env: "TORII_GITHUB_APP_ID",
61 bundled_client_id: Some("Ov23liDcA2Njn7eRWnYV"),
62 }),
63 "gitlab" => Some(DeviceFlowProvider {
64 device_authz_url: "https://gitlab.com/oauth/authorize_device",
65 token_url: "https://gitlab.com/oauth/token",
66 scopes: "api",
67 client_id_env: "TORII_GITLAB_APP_ID",
68 bundled_client_id: Some("b72a85262c309587f67591da8fed4f8e8f4ee7349e9ed06f6a2a99ee7caec4fe"),
69 }),
70 "codeberg" => Some(DeviceFlowProvider {
73 device_authz_url: "https://codeberg.org/login/oauth/device/code",
74 token_url: "https://codeberg.org/login/oauth/access_token",
75 scopes: "",
76 client_id_env: "TORII_CODEBERG_APP_ID",
77 bundled_client_id: Some("d114c8aa-227d-453e-8f25-cdd727f49d42"),
78 }),
79 _ => None,
80 }
81}
82
83#[derive(Debug, Deserialize)]
84struct DeviceCodeResponse {
85 device_code: String,
86 user_code: String,
87 verification_uri: String,
88 #[serde(default)]
89 verification_uri_complete: Option<String>,
90 #[serde(default)]
91 expires_in: u64,
92 #[serde(default = "default_interval")]
93 interval: u64,
94}
95
96fn default_interval() -> u64 { 5 }
97
98#[derive(Debug, Deserialize)]
99#[serde(untagged)]
100enum TokenResponse {
101 Success {
102 access_token: String,
103 #[serde(default)] #[allow(dead_code)] token_type: Option<String>,
104 #[serde(default)] refresh_token: Option<String>,
108 #[serde(default)] expires_in: Option<u64>,
109 },
110 Error { error: String, #[serde(default)] error_description: Option<String> },
111}
112
113pub fn run_device_flow(provider: &str) -> Result<String> {
118 let cfg = device_flow_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
119 "OAuth device flow not configured for `{}`. Supported: github, gitlab, codeberg. \
120 Bitbucket needs the (separate) Authorization Code flow.", provider
121 )))?;
122
123 let client_id = std::env::var(cfg.client_id_env).ok()
124 .or_else(|| cfg.bundled_client_id.map(String::from))
125 .ok_or_else(|| ToriiError::InvalidConfig(format!(
126 "No OAuth client_id available for `{}`. Set the {} env var, or wait until the \
127 bundled client_id ships in a future torii release. As a workaround, create a \
128 Personal Access Token in the platform's web UI and run: \
129 torii auth set {} YOUR_TOKEN",
130 provider, cfg.client_id_env, provider
131 )))?;
132
133 let client = crate::http::make_client();
134
135 let init_req = client.post(cfg.device_authz_url)
137 .header("Accept", "application/json")
138 .form(&[
139 ("client_id", client_id.as_str()),
140 ("scope", cfg.scopes),
141 ]);
142 let init: DeviceCodeResponse = crate::http::send_json(init_req, "OAuth device init")
143 .and_then(|v| serde_json::from_value(v).map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth device init: cannot parse response: {}", e) }))?;
144
145 let display_uri = init.verification_uri_complete.as_deref().unwrap_or(&init.verification_uri);
146 println!();
147 println!("⛩ Open this URL in your browser:");
148 println!(" {}", display_uri);
149 if init.verification_uri_complete.is_none() {
150 println!();
154 println!(" And enter the code: {}", init.user_code);
155 }
156 println!();
157 println!("Waiting for authorisation… (Ctrl-C to abort)");
158
159 let mut interval = Duration::from_secs(init.interval.max(1));
161 let deadline = Instant::now() + Duration::from_secs(init.expires_in.max(60));
162 loop {
163 std::thread::sleep(interval);
164 if Instant::now() >= deadline {
165 return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired before authorisation. Run the command again.".to_string() });
166 }
167
168 let poll_req = client.post(cfg.token_url)
169 .header("Accept", "application/json")
170 .form(&[
171 ("client_id", client_id.as_str()),
172 ("device_code", init.device_code.as_str()),
173 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
174 ]);
175
176 let resp = poll_req.send()
181 .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth poll: {}", e) })?;
182 let body: TokenResponse = resp.json()
183 .map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth poll: malformed JSON: {}", e) })?;
184 match body {
185 TokenResponse::Success { access_token, .. } => {
186 println!("✅ Authorised. Token saved.");
187 return Ok(access_token);
192 }
193 TokenResponse::Error { error, error_description } => match error.as_str() {
194 "authorization_pending" => continue,
195 "slow_down" => {
196 interval += Duration::from_secs(5);
197 continue;
198 }
199 "expired_token" => return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired. Run the command again.".to_string() }),
200 "access_denied" => return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: user denied authorisation.".to_string() }),
201 other => return Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
202 "OAuth device flow error '{}': {}",
203 other, error_description.unwrap_or_default()
204 ) }),
205 }
206 }
207 }
208}
209
210pub fn device_flow_supported(provider: &str) -> bool {
214 device_flow_provider(provider).is_some()
215}
216
217pub struct DeviceFlowSession {
223 pub provider: String,
224 pub verification_uri: String,
225 pub verification_uri_complete: Option<String>,
226 pub user_code: String,
227 pub display_uri: String,
231 device_code: String,
232 client_id: String,
233 token_url: String,
234 interval: Duration,
235 deadline: Instant,
236}
237
238#[derive(Debug)]
239pub enum DeviceFlowStep {
240 Pending,
243 SlowDown,
246 Done {
252 access_token: String,
253 refresh_token: Option<String>,
254 expires_in: Option<u64>,
255 },
256}
257
258pub fn start_device_flow(provider: &str) -> Result<DeviceFlowSession> {
259 let cfg = device_flow_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
260 "OAuth device flow not configured for `{}`. Supported: github, gitlab, codeberg.",
261 provider
262 )))?;
263 let client_id = std::env::var(cfg.client_id_env).ok()
264 .or_else(|| cfg.bundled_client_id.map(String::from))
265 .ok_or_else(|| ToriiError::InvalidConfig(format!(
266 "No OAuth client_id available for `{}`. Set the {} env var, or wait until \
267 the bundled client_id ships. As a workaround, create a Personal Access \
268 Token in the platform's web UI and run: torii auth set {} YOUR_TOKEN",
269 provider, cfg.client_id_env, provider
270 )))?;
271
272 let client = crate::http::make_client();
273 let init_req = client.post(cfg.device_authz_url)
274 .header("Accept", "application/json")
275 .form(&[
276 ("client_id", client_id.as_str()),
277 ("scope", cfg.scopes),
278 ]);
279 let init: DeviceCodeResponse = crate::http::send_json(init_req, "OAuth device init")
280 .and_then(|v| serde_json::from_value(v).map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth device init: cannot parse response: {}", e) }))?;
281
282 let display_uri = init.verification_uri_complete
283 .clone()
284 .unwrap_or_else(|| init.verification_uri.clone());
285 let interval = Duration::from_secs(init.interval.max(1));
286 let deadline = Instant::now() + Duration::from_secs(init.expires_in.max(60));
287
288 Ok(DeviceFlowSession {
289 provider: provider.to_string(),
290 verification_uri: init.verification_uri,
291 verification_uri_complete: init.verification_uri_complete,
292 user_code: init.user_code,
293 display_uri,
294 device_code: init.device_code,
295 client_id,
296 token_url: cfg.token_url.to_string(),
297 interval,
298 deadline,
299 })
300}
301
302pub fn poll_device_flow(session: &mut DeviceFlowSession) -> Result<DeviceFlowStep> {
306 if Instant::now() >= session.deadline {
307 return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired before authorisation.".to_string() });
308 }
309 let client = crate::http::make_client();
310 let poll_req = client.post(&session.token_url)
311 .header("Accept", "application/json")
312 .form(&[
313 ("client_id", session.client_id.as_str()),
314 ("device_code", session.device_code.as_str()),
315 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
316 ]);
317 let resp = poll_req.send()
318 .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth poll: {}", e) })?;
319 let body: TokenResponse = resp.json()
320 .map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth poll: malformed JSON: {}", e) })?;
321 match body {
322 TokenResponse::Success { access_token, refresh_token, expires_in, .. } =>
323 Ok(DeviceFlowStep::Done { access_token, refresh_token, expires_in }),
324 TokenResponse::Error { error, error_description } => match error.as_str() {
325 "authorization_pending" => Ok(DeviceFlowStep::Pending),
326 "slow_down" => {
327 session.interval += Duration::from_secs(5);
328 Ok(DeviceFlowStep::SlowDown)
329 }
330 "expired_token" => Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: code expired. Start again.".to_string() }),
331 "access_denied" => Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth device flow: user denied authorisation.".to_string() }),
332 other => Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
333 "OAuth device flow error '{}': {}",
334 other, error_description.unwrap_or_default()
335 ) }),
336 }
337 }
338}
339
340pub fn refresh_access_token(provider: &str, refresh_token: &str)
349 -> Result<(String, Option<String>, Option<u64>)>
350{
351 let cfg = device_flow_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
352 "OAuth refresh not configured for `{}`. Re-auth manually with `torii auth oauth {}`.",
353 provider, provider
354 )))?;
355 let client_id = std::env::var(cfg.client_id_env).ok()
356 .or_else(|| cfg.bundled_client_id.map(String::from))
357 .ok_or_else(|| ToriiError::InvalidConfig(format!(
358 "No OAuth client_id for `{}` refresh.", provider
359 )))?;
360
361 let client = crate::http::make_client();
362 let req = client.post(cfg.token_url)
363 .header("Accept", "application/json")
364 .form(&[
365 ("client_id", client_id.as_str()),
366 ("refresh_token", refresh_token),
367 ("grant_type", "refresh_token"),
368 ]);
369 let body: TokenResponse = req.send()
370 .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth refresh: {}", e) })?
371 .json()
372 .map_err(|e| ToriiError::MalformedResponse { provider: "oauth".into(), message: format!("OAuth refresh: malformed JSON: {}", e) })?;
373 match body {
374 TokenResponse::Success { access_token, refresh_token, expires_in, .. } => {
375 Ok((access_token, refresh_token, expires_in))
376 }
377 TokenResponse::Error { error, error_description } => {
378 Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
379 "OAuth refresh error '{}': {}",
380 error, error_description.unwrap_or_default()
381 ) })
382 }
383 }
384}
385
386pub fn device_flow_interval(session: &DeviceFlowSession) -> Duration {
390 session.interval
391}
392
393pub fn revoke_token(provider: &str, token: &str) -> Result<bool> {
400 match provider {
401 "gitlab" => revoke_gitlab(token),
402 "github" => revoke_github(token),
403 _ => Ok(false),
407 }
408}
409
410fn revoke_gitlab(token: &str) -> Result<bool> {
411 let client_id = std::env::var("TORII_GITLAB_APP_ID").ok()
417 .unwrap_or_else(|| "b72a85262c309587f67591da8fed4f8e8f4ee7349e9ed06f6a2a99ee7caec4fe".to_string());
418 let client = crate::http::make_client();
419 let req = client.post("https://gitlab.com/oauth/revoke")
420 .form(&[
421 ("client_id", client_id.as_str()),
422 ("token", token),
423 ("token_type_hint", "access_token"),
424 ]);
425 let resp = req.send().map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab revoke: {}", e) })?;
426 let status = resp.status().as_u16();
427 match status {
428 200 | 401 | 404 => Ok(true),
429 _ => {
430 let body = resp.text().unwrap_or_default();
431 Err(ToriiError::Network { provider: "gitlab".into(), message: format!(
432 "GitLab revoke returned HTTP {}: {}", status, body
433 ) })
434 }
435 }
436}
437
438fn revoke_github(token: &str) -> Result<bool> {
439 let client_id = std::env::var("TORII_GITHUB_APP_ID").ok()
444 .unwrap_or_else(|| "Ov23liDcA2Njn7eRWnYV".to_string());
445 let Ok(client_secret) = std::env::var("TORII_GITHUB_APP_SECRET") else {
446 return Ok(false);
447 };
448 let client = crate::http::make_client();
449 let url = format!("https://api.github.com/applications/{}/token", client_id);
450 let req = client.delete(&url)
451 .basic_auth(client_id.clone(), Some(client_secret))
452 .header("Accept", "application/vnd.github+json")
453 .json(&serde_json::json!({ "access_token": token }));
454 let resp = req.send().map_err(|e| ToriiError::Network { provider: "github".into(), message: format!("GitHub revoke: {}", e) })?;
455 let status = resp.status().as_u16();
456 match status {
457 204 | 404 | 422 => Ok(true),
458 _ => {
459 let body = resp.text().unwrap_or_default();
460 Err(ToriiError::Network { provider: "github".into(), message: format!(
461 "GitHub revoke returned HTTP {}: {}", status, body
462 ) })
463 }
464 }
465}
466
467pub fn revoke_hint_url(provider: &str) -> Option<&'static str> {
471 match provider {
472 "github" => Some("https://github.com/settings/applications"),
473 "gitlab" => Some("https://gitlab.com/-/profile/applications"),
474 "codeberg" => Some("https://codeberg.org/user/settings/applications"),
475 "bitbucket"=> Some("https://bitbucket.org/account/settings/app-authorizations/"),
476 _ => None,
477 }
478}
479
480pub fn rotate_gitlab_pat(token: &str) -> Result<String> {
486 let client = crate::http::make_client();
487 let req = client
488 .post("https://gitlab.com/api/v4/personal_access_tokens/self/rotate")
489 .header("Authorization", format!("Bearer {}", token));
490 let resp = req.send().map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab rotate PAT: {}", e) })?;
491 let status = resp.status().as_u16();
492 let body = resp.text().unwrap_or_default();
493 if status != 200 && status != 201 {
494 return Err(ToriiError::Network { provider: "gitlab".into(), message: format!(
495 "GitLab rotate PAT returned HTTP {}: {}", status, body
496 ) });
497 }
498 let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
499 ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("parse rotate response: {}", e) }
500 })?;
501 json["token"].as_str()
502 .map(String::from)
503 .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: format!(
504 "GitLab rotate PAT response missing `token`: {}", body
505 ) })
506}
507
508use std::io::{Read, Write as IoWrite};
524use std::net::TcpListener;
525
526struct AuthCodeProvider {
527 authorize_url: &'static str,
528 token_url: &'static str,
529 scopes: &'static str,
530 client_id_env: &'static str,
531 bundled_client_id: Option<&'static str>,
532 client_secret_env: Option<&'static str>,
538}
539
540fn auth_code_provider(provider: &str) -> Option<AuthCodeProvider> {
541 match provider {
542 "bitbucket" => Some(AuthCodeProvider {
543 authorize_url: "https://bitbucket.org/site/oauth2/authorize",
544 token_url: "https://bitbucket.org/site/oauth2/access_token",
545 scopes: "repository repository:write account pullrequest pullrequest:write issue:write pipeline",
546 client_id_env: "TORII_BITBUCKET_APP_ID",
547 bundled_client_id: Some("xQAkJEqx3LK4WtJ3KD"),
548 client_secret_env: Some("TORII_BITBUCKET_APP_SECRET"),
549 }),
550 _ => None,
551 }
552}
553
554const LOOPBACK_PORT: u16 = 8888;
555const LOOPBACK_PATH: &str = "/callback";
556
557pub fn run_auth_code_flow(provider: &str) -> Result<String> {
560 let cfg = auth_code_provider(provider).ok_or_else(|| ToriiError::InvalidConfig(format!(
561 "OAuth authorization-code flow not configured for `{}`.", provider
562 )))?;
563
564 let client_id = std::env::var(cfg.client_id_env).ok()
565 .or_else(|| cfg.bundled_client_id.map(String::from))
566 .ok_or_else(|| ToriiError::InvalidConfig(format!(
567 "No OAuth client_id for `{}`. Set {} or create a PAT manually and run \
568 `torii auth set {} ...`.",
569 provider, cfg.client_id_env, provider
570 )))?;
571
572 let client_secret = cfg.client_secret_env
573 .and_then(|name| std::env::var(name).ok());
574
575 let code_verifier = random_verifier();
579 let code_challenge = sha256_base64url(&code_verifier);
580
581 let redirect_uri = format!("http://localhost:{}{}", LOOPBACK_PORT, LOOPBACK_PATH);
583 let state = random_verifier(); let authz_url = format!(
585 "{}?client_id={}&response_type=code&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
586 cfg.authorize_url,
587 urlencode(&client_id),
588 urlencode(&redirect_uri),
589 urlencode(cfg.scopes),
590 urlencode(&state),
591 urlencode(&code_challenge),
592 );
593
594 let listener = TcpListener::bind(("127.0.0.1", LOOPBACK_PORT))
598 .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!(
599 "OAuth loopback: cannot bind 127.0.0.1:{} ({}). Is another flow already running?",
600 LOOPBACK_PORT, e
601 ) })?;
602
603 println!();
604 println!("⛩ Open this URL in your browser to authorise Torii:");
605 println!();
606 println!(" {}", authz_url);
607 println!();
608 println!("Waiting for the redirect on localhost:{}{}…", LOOPBACK_PORT, LOOPBACK_PATH);
609
610 let (mut stream, _addr) = listener.accept()
612 .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth loopback accept: {}", e) })?;
613
614 let mut buf = [0u8; 4096];
617 let n = stream.read(&mut buf)
618 .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth loopback read: {}", e) })?;
619 let request = String::from_utf8_lossy(&buf[..n]);
620 let request_line = request.lines().next().unwrap_or("");
621 let path_query = request_line.split_whitespace().nth(1).unwrap_or("");
623
624 let body = "<!doctype html><html><body><h2>⛩ Authorised — you can close this tab.</h2></body></html>";
628 let _ = write!(
629 stream,
630 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
631 body.len(), body
632 );
633
634 let (code, returned_state) = parse_callback(path_query)
635 .ok_or_else(|| ToriiError::Auth { provider: "oauth".into(), message: "OAuth callback didn't include a `code` parameter.".to_string() })?;
636
637 if returned_state != state {
638 return Err(ToriiError::Auth { provider: "oauth".into(), message: "OAuth state mismatch (possible CSRF). Run the command again.".to_string() });
639 }
640
641 let client = crate::http::make_client();
645 let mut params = vec![
646 ("grant_type", "authorization_code".to_string()),
647 ("code", code),
648 ("redirect_uri", redirect_uri),
649 ("client_id", client_id.clone()),
650 ("code_verifier", code_verifier),
651 ];
652 let mut req = client.post(cfg.token_url).header("Accept", "application/json");
653 if let Some(secret) = &client_secret {
654 use base64::Engine;
656 let b64 = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", client_id, secret));
657 req = req.header("Authorization", format!("Basic {}", b64));
658 } else {
659 params.push(("client_secret_present", "false".to_string()));
662 params.pop(); }
664 let resp = req.form(¶ms).send()
665 .map_err(|e| ToriiError::Auth { provider: "oauth".into(), message: format!("OAuth token exchange: {}", e) })?;
666 let json: serde_json::Value = resp.json()
667 .map_err(|e| ToriiError::Auth { provider: "oauth".into(), message: format!("OAuth token: malformed JSON: {}", e) })?;
668 if let Some(err) = json.get("error").and_then(|v| v.as_str()) {
669 return Err(ToriiError::Auth { provider: "oauth".into(), message: format!(
670 "OAuth token exchange failed: {} — {}", err,
671 json.get("error_description").and_then(|v| v.as_str()).unwrap_or("")
672 ) });
673 }
674 let token = json.get("access_token").and_then(|v| v.as_str())
675 .ok_or_else(|| ToriiError::Auth { provider: "oauth".into(), message: format!(
676 "OAuth token exchange: response had no access_token. Body: {}", json
677 ) })?
678 .to_string();
679
680 println!("✅ Authorised. Token saved.");
681 Ok(token)
682}
683
684fn parse_callback(path_query: &str) -> Option<(String, String)> {
687 let qs = path_query.split('?').nth(1)?;
688 let mut code = None;
689 let mut state = None;
690 for pair in qs.split('&') {
691 let mut iter = pair.splitn(2, '=');
692 match (iter.next(), iter.next()) {
693 (Some("code"), Some(v)) => code = Some(urldecode(v)),
694 (Some("state"), Some(v)) => state = Some(urldecode(v)),
695 _ => {}
696 }
697 }
698 Some((code?, state?))
699}
700
701fn random_verifier() -> String {
705 use std::time::{SystemTime, UNIX_EPOCH};
706 use std::sync::atomic::{AtomicU64, Ordering};
707 static COUNTER: AtomicU64 = AtomicU64::new(0);
708 let mut seed = [0u8; 48];
709 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
710 let pid = std::process::id() as u64;
711 let bump = COUNTER.fetch_add(1, Ordering::Relaxed);
712 seed[..8].copy_from_slice(&now.as_nanos().to_le_bytes()[..8]);
713 seed[8..16].copy_from_slice(&pid.to_le_bytes());
714 seed[16..24].copy_from_slice(&bump.to_le_bytes());
715 let hash = sha256_raw(&seed);
718 base64url_nopad(&hash)[..43].to_string()
719}
720
721fn sha256_raw(input: &[u8]) -> [u8; 32] {
724 use sha2::{Digest, Sha256};
727 let mut hasher = Sha256::new();
728 hasher.update(input);
729 hasher.finalize().into()
730}
731
732fn sha256_base64url(input: &str) -> String {
733 let digest = sha256_raw(input.as_bytes());
734 base64url_nopad(&digest)
735}
736
737fn base64url_nopad(bytes: &[u8]) -> String {
739 use base64::Engine;
740 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
741}
742
743fn urlencode(s: &str) -> String {
744 crate::url::encode(s)
745}
746
747fn urldecode(s: &str) -> String {
748 let mut out = String::with_capacity(s.len());
751 let bytes = s.as_bytes();
752 let mut i = 0;
753 while i < bytes.len() {
754 match bytes[i] {
755 b'+' => { out.push(' '); i += 1; }
756 b'%' if i + 2 < bytes.len() => {
757 let hi = (bytes[i+1] as char).to_digit(16);
758 let lo = (bytes[i+2] as char).to_digit(16);
759 if let (Some(hi), Some(lo)) = (hi, lo) {
760 out.push(((hi << 4) | lo) as u8 as char);
761 i += 3;
762 } else {
763 out.push(bytes[i] as char);
764 i += 1;
765 }
766 }
767 c => { out.push(c as char); i += 1; }
768 }
769 }
770 out
771}
772
773pub fn auth_code_flow_supported(provider: &str) -> bool {
775 auth_code_provider(provider).is_some()
776}