Skip to main content

torii_lib/util/
oauth.rs

1//! OAuth 2.0 Device Authorization Grant (RFC 8628) — the flow CLIs use
2//! to avoid asking the user to copy-paste a Personal Access Token.
3//!
4//! User experience:
5//!   1. torii prints a short code + the verification URL.
6//!   2. User opens the URL in any browser (no callback required), types
7//!      the code, authorises.
8//!   3. torii polls the token endpoint every `interval` seconds until
9//!      the server returns `access_token` (success) or `expired_token` /
10//!      `access_denied` (fail).
11//!
12//! The same shape works on GitHub, GitLab, Codeberg/Gitea/Forgejo. Each
13//! one needs a registered OAuth App so torii has a `client_id` to send
14//! — those are listed in [`device_flow_provider`].
15//!
16//! Bitbucket Cloud does **not** implement RFC 8628 — it only supports
17//! Authorization Code Grant, which needs a `localhost:PORT` callback.
18//! That flow is tracked separately and lives next to this module when
19//! implemented; for now Bitbucket continues to ask for an app password
20//! via `torii auth set bitbucket USERNAME:APP_PASSWORD`.
21
22use std::time::{Duration, Instant};
23
24use serde::Deserialize;
25
26use crate::error::{Result, ToriiError};
27
28/// Per-provider URLs + scopes for the device-flow request. Add new
29/// entries here; nothing else needs to change to wire a new provider
30/// (assuming it implements RFC 8628).
31struct DeviceFlowProvider {
32    /// Where to POST the initial device-code request.
33    device_authz_url: &'static str,
34    /// Where to POST the token poll.
35    token_url: &'static str,
36    /// Default OAuth scopes string sent with the device-code request.
37    scopes: &'static str,
38    /// Env var the user can set to override the bundled client_id at
39    /// runtime — useful for self-hosted Gitea/Forgejo where the user
40    /// registers their own OAuth app.
41    client_id_env: &'static str,
42    /// Bundled (public) client_id. **Has to be filled in once an OAuth
43    /// app is registered on each platform**; until then we fall back
44    /// to the env var and bail with a helpful error if it's missing.
45    bundled_client_id: Option<&'static str>,
46}
47
48fn device_flow_provider(provider: &str) -> Option<DeviceFlowProvider> {
49    // The `bundled_client_id` slots are the public OAuth App IDs
50    // registered for "Torii CLI" on each platform. They identify the
51    // app to the auth server — they are NOT secrets and live in the
52    // open-source binary intentionally (same as `gh`, `glab`, etc.).
53    // The env var override lets users point at their own registered
54    // app (useful for self-hosted Gitea/Forgejo).
55    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 / Gitea / Forgejo share the Gitea OAuth surface; the
71        // device-flow endpoints are at the platform host.
72        "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        // 0.7.39 — GitLab device flow returns these. GitHub OAuth
105        // Apps don't, so we keep them Option-typed and the helpers
106        // that need them check for `Some(...)` before using.
107        #[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
113/// Run the device flow for `provider`. Blocks until the user
114/// authorises (success) or the device code expires (failure).
115/// Returns the access token, ready to hand to
116/// [`crate::auth::set_token`].
117pub 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    // Step 1: request device + user codes.
136    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        // Only print the code separately when the URL doesn't already
151        // embed it — otherwise the user copies a code that's also in
152        // the link, which is noisy.
153        println!();
154        println!("   And enter the code: {}", init.user_code);
155    }
156    println!();
157    println!("Waiting for authorisation… (Ctrl-C to abort)");
158
159    // Step 2: poll the token endpoint.
160    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        // We bypass send_json here because the token endpoint returns
177        // 200 for "still pending" responses — only the body
178        // distinguishes success from in-flight, so the standard
179        // is_success() check would mis-handle the error variants.
180        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                // The blocking CLI path doesn't persist
188                // refresh_token (set_token() is what it calls
189                // afterwards, and it doesn't have a refresh slot).
190                // The in-TUI worker (start_oauth_flow) does.
191                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
210/// Whether the given provider has device flow wired (without checking
211/// whether a client_id is actually available — that's the runtime
212/// concern of [`run_device_flow`]).
213pub fn device_flow_supported(provider: &str) -> bool {
214    device_flow_provider(provider).is_some()
215}
216
217/// 0.7.32 — split device flow for the TUI. `start_device_flow` does the
218/// initial POST that gives the user a URL + user_code; the caller then
219/// drives the poll one tick at a time via [`poll_device_flow`], so it
220/// can render progress in a modal instead of blocking the main loop.
221/// The blocking [`run_device_flow`] is now a thin loop over these two.
222pub struct DeviceFlowSession {
223    pub provider: String,
224    pub verification_uri: String,
225    pub verification_uri_complete: Option<String>,
226    pub user_code: String,
227    /// URI we tell the user to actually open. `verification_uri_complete`
228    /// when the provider supplies it (already embeds the code),
229    /// otherwise just `verification_uri`.
230    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    /// User hasn't authorised yet; caller should sleep `interval` and
241    /// poll again.
242    Pending,
243    /// Provider asked us to back off; caller should sleep
244    /// `interval` (which was just increased) before the next poll.
245    SlowDown,
246    /// Final state — access token in hand. Carries the refresh token
247    /// + expiry hint so `auth::set_token_with_refresh` can persist
248    /// them and `auth::refresh_if_needed` can renew without prompting
249    /// the user again. `None` when the provider didn't issue one
250    /// (GitHub OAuth Apps).
251    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
302/// Run one poll of the token endpoint. Mutates `session.interval` if
303/// the provider asks us to slow down. The caller is responsible for
304/// sleeping between calls — we want the TUI loop to keep ticking.
305pub 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
340/// 0.7.39 — exchange a refresh_token for a fresh access_token.
341/// GitLab supports this end-to-end with the same token endpoint used
342/// by the device flow; GitHub OAuth Apps don't issue refresh tokens
343/// for device flow, so callers should check before calling.
344///
345/// Returns `(new_access_token, new_refresh_token, expires_in_seconds)`
346/// — providers may rotate the refresh token, so we always store
347/// whatever they hand back.
348pub 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
386/// Poll interval suggested by the provider (updated when the provider
387/// asks us to slow down). Caller sleeps for this between
388/// `poll_device_flow` calls.
389pub fn device_flow_interval(session: &DeviceFlowSession) -> Duration {
390    session.interval
391}
392
393/// Best-effort revoke of an access token, used by `torii auth rotate`
394/// after the new token is in hand. Returns Ok(true) if the platform
395/// confirmed the revocation, Ok(false) if no revoke endpoint is wired
396/// (the caller should print a "revoke manually at <URL>" hint), or
397/// Err on a real failure (network, 5xx, malformed response). 401/404
398/// from the revoke endpoint count as "already invalid" → Ok(true).
399pub fn revoke_token(provider: &str, token: &str) -> Result<bool> {
400    match provider {
401        "gitlab" => revoke_gitlab(token),
402        "github" => revoke_github(token),
403        // Codeberg/Gitea: no stable OAuth revoke endpoint in the
404        // Gitea spec, only PAT delete by ID — caller falls back to
405        // a manual hint.
406        _ => Ok(false),
407    }
408}
409
410fn revoke_gitlab(token: &str) -> Result<bool> {
411    // RFC 7009 — GitLab accepts revoke without client_secret for
412    // public clients. The bundled torii client is registered that
413    // way; users with a custom TORII_GITLAB_APP_ID must also have
414    // it configured as a public client (the env-var fallback path
415    // is for self-managed GitLab where the user controls both).
416    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    // GitHub's `DELETE /applications/{client_id}/token` is the only
440    // documented way to revoke an OAuth token, and it requires Basic
441    // auth with client_id + client_secret. Bundled apps don't ship
442    // their secret; users running their own app can set the env var.
443    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
467/// Where the user should go to revoke an OAuth token manually when
468/// `revoke_token` returns Ok(false) (no programmatic endpoint). Used
469/// in `torii auth rotate` to print a helpful hint.
470pub 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
480/// GitLab-specific: rotate a PAT in place via the native
481/// `POST /personal_access_tokens/self/rotate` endpoint. Returns the
482/// new token text. Requires the current token to have `api` scope.
483/// Only GitLab supports this; for other platforms callers should
484/// fall back to the OAuth rotate path.
485pub 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
508// =============================================================================
509// OAuth 2.0 Authorization Code Grant with PKCE + loopback HTTP server.
510//
511// Used for providers that don't implement RFC 8628 Device Flow — most
512// notably Bitbucket Cloud. Torii:
513//   1. Generates a random code_verifier + its SHA-256 code_challenge.
514//   2. Binds a localhost TCP listener (port 8888 by default).
515//   3. Opens the platform's /authorize URL in the user's browser with
516//      redirect_uri pointing at the loopback.
517//   4. Waits for the browser to GET `/callback?code=...`.
518//   5. Exchanges the code for an access_token at the token endpoint,
519//      sending the code_verifier (PKCE — no client_secret needed for
520//      public OAuth clients on platforms that honour PKCE).
521// =============================================================================
522
523use 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    /// Env var name for the OAuth client_secret. Some providers (e.g.
533    /// Bitbucket) hand out a secret on every consumer registration;
534    /// even with PKCE they expect it on the token-exchange call. The
535    /// secret is **not** bundled in the binary — has to come from the
536    /// user's env / .env file.
537    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
557/// Run the authorization-code flow for `provider`. Blocks until the
558/// user authorises (success) or the listener is interrupted.
559pub 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    // PKCE: random verifier + SHA-256 challenge. RFC 7636 demands the
576    // verifier be 43-128 unreserved chars; we generate 64 base64-url
577    // characters.
578    let code_verifier = random_verifier();
579    let code_challenge = sha256_base64url(&code_verifier);
580
581    // Build the authorize URL.
582    let redirect_uri = format!("http://localhost:{}{}", LOOPBACK_PORT, LOOPBACK_PATH);
583    let state = random_verifier(); // CSRF token
584    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    // Bind the loopback listener BEFORE printing the URL so we can
595    // fail fast if the port is busy. Lossless: if another torii flow
596    // is in progress on 8888 the user finds out immediately.
597    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    // Accept a single connection.
611    let (mut stream, _addr) = listener.accept()
612        .map_err(|e| ToriiError::Network { provider: "oauth".into(), message: format!("OAuth loopback accept: {}", e) })?;
613
614    // Read the request line + a bit of the headers — we only need the
615    // URL path with the code+state query string.
616    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    // `GET /callback?code=...&state=... HTTP/1.1`
622    let path_query = request_line.split_whitespace().nth(1).unwrap_or("");
623
624    // Always respond with something so the browser doesn't show an
625    // error page — this happens before we know whether the code is
626    // valid, so the response is best-effort.
627    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    // Exchange the code for a token. Bitbucket accepts both client
642    // secret (Basic auth) and PKCE-only — we send the secret if
643    // available, fall back to PKCE alone.
644    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        // Bitbucket prefers Basic auth for confidential consumers.
655        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        // Public-client flow — Bitbucket needs client_id in the body
660        // too; already added above.
661        params.push(("client_secret_present", "false".to_string()));
662        params.pop(); // remove the placeholder
663    }
664    let resp = req.form(&params).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
684/// Parse `/callback?code=XYZ&state=ABC` → `(code, state)`. Tolerant of
685/// extra parameters and ordering.
686fn 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
701/// Random 64-character base64url string. Uses the OS RNG via
702/// `std::time` mixed with a per-process counter — enough entropy for a
703/// short-lived PKCE verifier without pulling in `rand`.
704fn 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    // Hash the seed to widen entropy — PKCE verifier doesn't need
716    // cryptographic randomness, just unguessability.
717    let hash = sha256_raw(&seed);
718    base64url_nopad(&hash)[..43].to_string()
719}
720
721/// SHA-256 of input. Implemented inline to avoid pulling another dep
722/// just for this — we already use base64; sha2 would be the alternative.
723fn sha256_raw(input: &[u8]) -> [u8; 32] {
724    // Use the sha2 crate (already in the tree transitively via reqwest
725    // → rustls → ring). Add it explicitly to Cargo.toml.
726    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
737/// base64url without padding (RFC 4648 §5).
738fn 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    // Tolerant decoder: handles `%XX` and `+`. Doesn't validate utf-8
749    // beyond what the platform would; OAuth codes are ASCII anyway.
750    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
773/// Whether the given provider has an authorization-code flow wired.
774pub fn auth_code_flow_supported(provider: &str) -> bool {
775    auth_code_provider(provider).is_some()
776}