diff --git a/.cspell/dicts/project.txt b/.cspell/dicts/project.txt index d0582f1..d14f9f8 100644 --- a/.cspell/dicts/project.txt +++ b/.cspell/dicts/project.txt @@ -22,3 +22,6 @@ anstream coderabbitai nulab truecolor +getrandom +CSPRNG +subsec diff --git a/Cargo.lock b/Cargo.lock index b6b1650..4e308fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,8 +341,10 @@ dependencies = [ "anyhow", "clap", "dirs", + "getrandom 0.3.4", "httpmock", "keyring", + "open", "owo-colors", "reqwest", "rpassword", @@ -1173,6 +1175,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1406,6 +1427,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -1434,6 +1466,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/Cargo.toml b/Cargo.toml index a9fe5eb..b3a987b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ rpassword = "7" keyring = { version = "3", default-features = false, features = ["async-secret-service", "crypto-rust"] } anstream = "0.6" owo-colors = "4" +open = "5" +getrandom = "0.3" [target.'cfg(windows)'.dependencies] keyring = { version = "3", features = ["windows-native"] } diff --git a/docs/user-guide.ja.md b/docs/user-guide.ja.md index 9912100..97dcd15 100644 --- a/docs/user-guide.ja.md +++ b/docs/user-guide.ja.md @@ -14,7 +14,7 @@ ## 前提条件 - 少なくとも 1 つのスペースにアクセスできる [Backlog](https://backlog.com) アカウント -- Backlog API キー([認証](#認証) を参照) +- Backlog API キーまたは OAuth 2.0 クライアント認証情報([認証](#認証) を参照) ## インストール @@ -104,7 +104,7 @@ irm https://raw.githubusercontent.com/23prime/backlog-cli/latest/uninstall.ps1 | 3. メモを入力して **送信** をクリックします 4. 生成された API キーをコピーします -### ログイン +### API キーでログイン ```bash bl auth login @@ -119,6 +119,41 @@ bl auth login 別のスペースキーで `bl auth login` を再実行すると、そのスペースが追加されます。 最後にログインしたスペースがカレント(アクティブ)スペースになります。 +### OAuth 2.0 でログイン + +API キーの代わりに、ブラウザベースの OAuth 2.0 認証を使用できます。 + +#### ステップ 1 — Backlog で OAuth アプリケーションを登録する + +1. を開きます +2. 新しいアプリケーションを作成します: + - **アプリケーション種別**: Confidential Client + - **リダイレクト URI**: `http://127.0.0.1:54321/callback` + (`--port ` を使用する場合は `http://127.0.0.1:/callback`) +3. **クライアント ID** と **クライアントシークレット** を控えておきます + +#### ステップ 2 — OAuth ログインコマンドを実行する + +```bash +bl auth login-oauth +``` + +以下の入力を求められます。 + +- **スペースキー** — Backlog スペースのサブドメイン +- **クライアント ID** — 登録したアプリケーションのクライアント ID +- **クライアントシークレット** — 登録したアプリケーションのクライアントシークレット(入力は非表示) + +コマンドを実行するとブラウザが開き、Backlog の認証画面が表示されます。 +承認後、ブラウザは `http://127.0.0.1:54321/callback` にリダイレクトされ、 +アクセストークンが自動的に保存されます。 + +カスタムポートを使用する場合(Backlog に登録したリダイレクト URI と一致させてください): + +```bash +bl auth login-oauth --port 8080 +``` + ### 複数スペースの管理 ```bash @@ -151,11 +186,23 @@ Backlog API に対して認証情報を検証し、以下のように表示し ```text Space: mycompany.backlog.com + - Auth method: API key - API key: abcd... - Stored in: System keyring - Logged in as Your Name (your-id) ``` +OAuth 2.0 認証の場合: + +```text +Space: mycompany.backlog.com + - Auth method: OAuth 2.0 + - Client ID: abc123 + - Client Secret: abcd... + - Access token: abcd... + - Logged in as Your Name (your-id) +``` + `BL_API_KEY` が設定されている場合、`Stored in` には `Environment variable` と表示されます。 ### ログアウト @@ -186,6 +233,7 @@ bl auth logout --all | コマンド | 説明 | | --- | --- | | `bl auth login` | Backlog API キーで認証(スペースを追加または更新)。`--no-banner` でバナーをスキップ | +| `bl auth login-oauth` | ブラウザベースの OAuth 2.0 で認証。`--port ` でコールバックポートを変更(デフォルト: 54321)。`--no-banner` でバナーをスキップ | | `bl auth status` | 現在の認証状態を表示して認証情報を検証 | | `bl auth list` | 設定済みスペースの一覧を表示 | | `bl auth use ` | カレントスペースを切り替え | @@ -799,16 +847,18 @@ bl wiki attachment list 12345 --json | 場所 | 内容 | | --- | --- | | `~/.config/bl/config.toml` | スペースキー(非機密メタデータ) | -| システムキーリング | API キー(優先; GNOME Keyring / Keychain) | +| システムキーリング | API キーと OAuth トークン(優先; GNOME Keyring / Keychain) | | `~/.config/bl/credentials.toml` | API キーのフォールバック(mode 0600、キーリングが使えない場合) | +| `~/.config/bl/oauth_tokens.toml` | OAuth トークンのフォールバック(mode 0600、キーリングが使えない場合) | ### Windows | 場所 | 内容 | | --- | --- | | `%APPDATA%\bl\config.toml` | スペースキー(非機密メタデータ) | -| Windows 資格情報マネージャー | API キー(優先) | +| Windows 資格情報マネージャー | API キーと OAuth トークン(優先) | | `%APPDATA%\bl\credentials.toml` | API キーのフォールバック(資格情報マネージャーが使えない場合) | +| `%APPDATA%\bl\oauth_tokens.toml` | OAuth トークンのフォールバック(資格情報マネージャーが使えない場合) | ### 設定ファイルの形式 @@ -837,10 +887,10 @@ API キーがキーリングに見つかりません。`bl auth login` を再実 ### キーリングが利用できない Linux では、キーリングには Secret Service デーモン(GNOME Keyring または KWallet)が起動している必要があります。 -デーモンが利用できない場合(ヘッドレス環境や SSH 経由など)、`bl` は自動的に `~/.config/bl/credentials.toml`(mode 0600)にフォールバックします。 +デーモンが利用できない場合(ヘッドレス環境や SSH 経由など)、`bl` は API キーを `~/.config/bl/credentials.toml`、OAuth トークンを `~/.config/bl/oauth_tokens.toml`(いずれも mode 0600)に自動的にフォールバックします。 macOS ではシステムの Keychain、Windows では Windows 資格情報マネージャーが使用されます。 -資格情報マネージャーが利用できない場合は `%APPDATA%\bl\credentials.toml` にフォールバックします。 +資格情報マネージャーが利用できない場合は、API キーは `%APPDATA%\bl\credentials.toml`、OAuth トークンは `%APPDATA%\bl\oauth_tokens.toml` にフォールバックします。 `bl auth status` の出力で使用中のバックエンドを確認できます。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 4e546ef..584b4e7 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -14,7 +14,7 @@ ## Prerequisites - A [Backlog](https://backlog.com) account with access to at least one space -- A Backlog API key (see [Authentication](#authentication)) +- A Backlog API key or OAuth 2.0 client credentials (see [Authentication](#authentication)) ## Installation @@ -108,7 +108,7 @@ To also remove stored credentials and configuration files, pass `-Purge`: 3. Enter a memo and click **Submit** 4. Copy the generated API key -### Logging in +### Logging in with an API key ```bash bl auth login @@ -123,6 +123,41 @@ You will be prompted for: Running `bl auth login` again with a different space key adds another space. The most recently logged-in space becomes the current (active) space. +### Logging in with OAuth 2.0 + +`bl` supports browser-based OAuth 2.0 login as an alternative to API keys. + +#### Step 1 — Register an OAuth application in Backlog + +1. Open +2. Create a new application: + - **Application type**: Confidential Client + - **Redirect URI**: `http://127.0.0.1:54321/callback` + (use `http://127.0.0.1:/callback` if you will pass `--port `) +3. Note the **Client ID** and **Client Secret** + +#### Step 2 — Run the OAuth login command + +```bash +bl auth login-oauth +``` + +You will be prompted for: + +- **Space key** — the subdomain of your Backlog space +- **Client ID** — from the registered application +- **Client Secret** — from the registered application (input is hidden) + +The command opens your browser to the Backlog authorization page. +After you approve, the browser is redirected to `http://127.0.0.1:54321/callback` +and the access token is stored automatically. + +To use a custom port (must match the Redirect URI registered in Backlog): + +```bash +bl auth login-oauth --port 8080 +``` + ### Managing multiple spaces ```bash @@ -155,11 +190,23 @@ This verifies your credentials against the Backlog API and shows: ```text Space: mycompany.backlog.com + - Auth method: API key - API key: abcd... - Stored in: System keyring - Logged in as Your Name (your-id) ``` +When authenticated via OAuth: + +```text +Space: mycompany.backlog.com + - Auth method: OAuth 2.0 + - Client ID: abc123 + - Client Secret: abcd... + - Access token: abcd... + - Logged in as Your Name (your-id) +``` + When `BL_API_KEY` is set, `Stored in` shows `Environment variable`. ### Logging out @@ -190,6 +237,7 @@ bl auth logout --all | Command | Description | | --- | --- | | `bl auth login` | Authenticate with a Backlog API key (adds or updates a space); use `--no-banner` to skip the banner | +| `bl auth login-oauth` | Authenticate via browser-based OAuth 2.0; use `--port ` to override the default callback port (54321); use `--no-banner` to skip the banner | | `bl auth status` | Show current auth status and verify credentials | | `bl auth list` | List all configured spaces | | `bl auth use ` | Switch the current space | @@ -804,16 +852,18 @@ Commands that target a specific project accept a `--project ` flag. | Location | Contents | | --- | --- | | `~/.config/bl/config.toml` | Space key (non-sensitive metadata) | -| System keyring | API key (primary; GNOME Keyring / Keychain) | +| System keyring | API key and OAuth tokens (primary; GNOME Keyring / Keychain) | | `~/.config/bl/credentials.toml` | API key fallback (mode 0600, used when keyring is unavailable) | +| `~/.config/bl/oauth_tokens.toml` | OAuth token fallback (mode 0600, used when keyring is unavailable) | ### Windows | Location | Contents | | --- | --- | | `%APPDATA%\bl\config.toml` | Space key (non-sensitive metadata) | -| Windows Credential Manager | API key (primary) | +| Windows Credential Manager | API key and OAuth tokens (primary) | | `%APPDATA%\bl\credentials.toml` | API key fallback (used when Credential Manager is unavailable) | +| `%APPDATA%\bl\oauth_tokens.toml` | OAuth token fallback (used when Credential Manager is unavailable) | ### Config file format @@ -843,10 +893,12 @@ Run `bl auth login` to re-enter your credentials. On Linux, the keyring requires a running Secret Service daemon (GNOME Keyring or KWallet). If no daemon is available (e.g. headless or SSH environments), `bl` automatically falls back -to storing the API key in `~/.config/bl/credentials.toml` with mode 0600. +to storing the API key in `~/.config/bl/credentials.toml` and OAuth tokens in +`~/.config/bl/oauth_tokens.toml`, both with mode 0600. On macOS, the system Keychain is used. On Windows, the Windows Credential Manager is used. -If the Credential Manager is unavailable, `bl` falls back to `%APPDATA%\bl\credentials.toml`. +If the Credential Manager is unavailable, `bl` falls back to `%APPDATA%\bl\credentials.toml` +(API key) and `%APPDATA%\bl\oauth_tokens.toml` (OAuth tokens). The `bl auth status` output shows which backend is in use: diff --git a/src/api/mod.rs b/src/api/mod.rs index f84e423..1b5db15 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,10 @@ use anyhow::{Context, Result}; use reqwest::blocking::Client; +use std::cell::RefCell; +use std::time::Duration; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); pub mod activity; pub mod disk_usage; @@ -197,114 +202,171 @@ impl BacklogApi for BacklogClient { } } +/// How the client authenticates with Backlog. +pub(crate) enum AuthMethod { + /// API key passed as `?apiKey=` query parameter. + ApiKey(String), + /// OAuth 2.0 Bearer token. Both tokens are wrapped in `RefCell` so that + /// `try_refresh` can update them through a shared `&self` reference. + Bearer { + access_token: RefCell, + refresh_token: RefCell, + client_id: String, + client_secret: String, + space_key: String, + }, +} + pub struct BacklogClient { client: Client, base_url: String, - api_key: String, + pub(crate) auth: AuthMethod, } impl BacklogClient { pub fn from_config() -> Result { let space_key = crate::config::current_space_key()?; - let (api_key, _) = crate::secret::current_api_key(&space_key)?; - let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) .build() .context("Failed to build HTTP client")?; + let base_url = format!("https://{space_key}.backlog.com/api/v2"); + + // OAuth tokens take priority over API key. + if let Ok((tokens, _backend)) = crate::secret::get_oauth_tokens(&space_key) { + return Ok(Self { + client, + base_url, + auth: AuthMethod::Bearer { + access_token: RefCell::new(tokens.access_token), + refresh_token: RefCell::new(tokens.refresh_token), + client_id: tokens.client_id, + client_secret: tokens.client_secret, + space_key, + }, + }); + } + let (api_key, _) = crate::secret::current_api_key(&space_key)?; Ok(Self { client, - base_url: format!("https://{}.backlog.com/api/v2", space_key), - api_key, + base_url, + auth: AuthMethod::ApiKey(api_key), }) } - pub fn get(&self, path: &str) -> Result { - let url = format!("{}{}", self.base_url, path); - let response = self - .client - .get(&url) - .query(&[("apiKey", &self.api_key)]) - .send() - .with_context(|| format!("Failed to GET {}", url))?; + /// Apply authentication to a request builder (query param or header). + fn apply_auth( + &self, + builder: reqwest::blocking::RequestBuilder, + ) -> reqwest::blocking::RequestBuilder { + match &self.auth { + AuthMethod::ApiKey(key) => builder.query(&[("apiKey", key.as_str())]), + AuthMethod::Bearer { access_token, .. } => { + builder.header("Authorization", format!("Bearer {}", access_token.borrow())) + } + } + } + /// Parse a successful or error response body. + fn finish_response(&self, response: reqwest::blocking::Response) -> Result { let status = response.status(); let body: serde_json::Value = response.json().context("Failed to parse JSON response")?; - if !status.is_success() { anyhow::bail!("API error ({}): {}", status, extract_error_message(&body)); } - Ok(body) } + /// Try to refresh an OAuth access token. Returns `true` if the token was + /// refreshed successfully, `false` if auth is API-key-based. + /// Propagates refresh errors so callers receive an informative error + /// instead of silently falling back to a stale 401. + fn try_refresh(&self) -> Result { + let AuthMethod::Bearer { + access_token, + refresh_token, + client_id, + client_secret, + space_key, + } = &self.auth + else { + return Ok(false); + }; + + let current = crate::oauth::OAuthTokens { + client_id: client_id.clone(), + client_secret: client_secret.clone(), + access_token: access_token.borrow().clone(), + refresh_token: refresh_token.borrow().clone(), + }; + + let new_tokens = crate::oauth::refresh_access_token(space_key, ¤t)?; + *access_token.borrow_mut() = new_tokens.access_token.clone(); + *refresh_token.borrow_mut() = new_tokens.refresh_token.clone(); + crate::secret::set_oauth_tokens(space_key, &new_tokens)?; + Ok(true) + } + + /// Send a request (built by `factory`) and retry once on 401 by refreshing + /// the OAuth token. `factory` is called at most twice. + fn execute(&self, factory: F) -> Result + where + F: Fn() -> Result, + { + let response = factory()?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED && self.try_refresh()? { + return self.finish_response(factory()?); + } + self.finish_response(response) + } + + pub fn get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base_url, path); + self.execute(|| { + self.apply_auth(self.client.get(&url)) + .send() + .with_context(|| format!("Failed to GET {url}")) + }) + } + pub fn get_with_query( &self, path: &str, params: &[(String, String)], ) -> Result { let url = format!("{}{}", self.base_url, path); - let mut query: Vec<(&str, &str)> = vec![("apiKey", &self.api_key)]; let extra: Vec<(&str, &str)> = params .iter() .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - query.extend(extra.iter().copied()); - let response = self - .client - .get(&url) - .query(&query) - .send() - .with_context(|| format!("Failed to GET {}", url))?; - - let status = response.status(); - let body: serde_json::Value = response.json().context("Failed to parse JSON response")?; - - if !status.is_success() { - anyhow::bail!("API error ({}): {}", status, extract_error_message(&body)); - } - - Ok(body) + self.execute(|| { + self.apply_auth(self.client.get(&url)) + .query(&extra) + .send() + .with_context(|| format!("Failed to GET {url}")) + }) } pub fn post_form(&self, path: &str, params: &[(String, String)]) -> Result { let url = format!("{}{}", self.base_url, path); - let response = self - .client - .post(&url) - .query(&[("apiKey", &self.api_key)]) - .form(params) - .send() - .with_context(|| format!("Failed to POST {}", url))?; - - let status = response.status(); - let body: serde_json::Value = response.json().context("Failed to parse JSON response")?; - - if !status.is_success() { - anyhow::bail!("API error ({}): {}", status, extract_error_message(&body)); - } - - Ok(body) + self.execute(|| { + self.apply_auth(self.client.post(&url)) + .form(params) + .send() + .with_context(|| format!("Failed to POST {url}")) + }) } pub fn patch_form(&self, path: &str, params: &[(String, String)]) -> Result { let url = format!("{}{}", self.base_url, path); - let response = self - .client - .patch(&url) - .query(&[("apiKey", &self.api_key)]) - .form(params) - .send() - .with_context(|| format!("Failed to PATCH {}", url))?; - - let status = response.status(); - let body: serde_json::Value = response.json().context("Failed to parse JSON response")?; - - if !status.is_success() { - anyhow::bail!("API error ({}): {}", status, extract_error_message(&body)); - } - - Ok(body) + self.execute(|| { + self.apply_auth(self.client.patch(&url)) + .form(params) + .send() + .with_context(|| format!("Failed to PATCH {url}")) + }) } pub fn delete_form( @@ -313,41 +375,21 @@ impl BacklogClient { params: &[(String, String)], ) -> Result { let url = format!("{}{}", self.base_url, path); - let response = self - .client - .delete(&url) - .query(&[("apiKey", &self.api_key)]) - .form(params) - .send() - .with_context(|| format!("Failed to DELETE {}", url))?; - - let status = response.status(); - let body: serde_json::Value = response.json().context("Failed to parse JSON response")?; - - if !status.is_success() { - anyhow::bail!("API error ({}): {}", status, extract_error_message(&body)); - } - - Ok(body) + self.execute(|| { + self.apply_auth(self.client.delete(&url)) + .form(params) + .send() + .with_context(|| format!("Failed to DELETE {url}")) + }) } pub fn delete_req(&self, path: &str) -> Result { let url = format!("{}{}", self.base_url, path); - let response = self - .client - .delete(&url) - .query(&[("apiKey", &self.api_key)]) - .send() - .with_context(|| format!("Failed to DELETE {}", url))?; - - let status = response.status(); - let body: serde_json::Value = response.json().context("Failed to parse JSON response")?; - - if !status.is_success() { - anyhow::bail!("API error ({}): {}", status, extract_error_message(&body)); - } - - Ok(body) + self.execute(|| { + self.apply_auth(self.client.delete(&url)) + .send() + .with_context(|| format!("Failed to DELETE {url}")) + }) } } @@ -362,12 +404,14 @@ fn extract_error_message(body: &serde_json::Value) -> &str { impl BacklogClient { pub(crate) fn new_with(base_url: &str, api_key: &str) -> Result { let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) .build() .context("Failed to build HTTP client")?; Ok(Self { client, base_url: base_url.to_string(), - api_key: api_key.to_string(), + auth: AuthMethod::ApiKey(api_key.to_string()), }) } } diff --git a/src/cmd/auth.rs b/src/cmd/auth.rs index 3ea749b..9700cf8 100644 --- a/src/cmd/auth.rs +++ b/src/cmd/auth.rs @@ -4,6 +4,7 @@ use owo_colors::OwoColorize; use crate::api::{BacklogApi, BacklogClient, user::User}; use crate::config::{self}; +use crate::oauth::{self}; use crate::secret::{self, Backend}; pub fn login(no_banner: bool) -> Result<()> { @@ -32,6 +33,42 @@ pub fn login(no_banner: bool) -> Result<()> { Ok(()) } +pub fn login_oauth(no_banner: bool, port: u16) -> Result<()> { + if !no_banner { + println!("Welcome to"); + crate::cmd::banner::print_banner(); + } + + println!("Backlog OAuth Login\n"); + println!(" Register an OAuth 2.0 application (Confidential Client) at:"); + println!( + " {}", + "https://backlog.com/developer/applications/oauth2Clients/add".bold() + ); + println!( + " Redirect URI: {}\n", + format!("http://127.0.0.1:{port}/callback").bold() + ); + + let space_key = prompt("Space key (e.g. mycompany for mycompany.backlog.com): ")?; + let client_id = prompt("Client ID: ")?; + let client_secret = + rpassword::prompt_password("Client secret: ").context("Failed to read client secret")?; + + let tokens = oauth::run_oauth_flow(&space_key, &client_id, &client_secret, port)?; + secret::set_oauth_tokens(&space_key, &tokens)?; + + let mut cfg = config::load()?; + if !cfg.spaces.contains(&space_key) { + cfg.spaces.push(space_key.clone()); + } + cfg.current_space = Some(space_key); + config::save(&cfg)?; + + println!("{}", "Logged in successfully via OAuth.".green()); + Ok(()) +} + pub fn logout(space_key: Option<&str>) -> Result<()> { let mut cfg = config::load()?; @@ -44,6 +81,7 @@ pub fn logout(space_key: Option<&str>) -> Result<()> { }; secret::delete(&key)?; + secret::delete_oauth_tokens(&key)?; remove_space_from_config(&mut cfg, &key); config::save(&cfg)?; @@ -56,6 +94,7 @@ pub fn logout_all() -> Result<()> { for key in &cfg.spaces { secret::delete(key)?; + secret::delete_oauth_tokens(key)?; } config::remove_config_file()?; @@ -113,12 +152,24 @@ impl AuthStatusArgs { } } +/// Auth information displayed by `bl auth status`. +pub enum AuthDisplay { + ApiKey { + masked: String, + backend: Backend, + }, + OAuth { + masked_token: String, + client_id: String, + masked_client_secret: String, + backend: Backend, + }, +} + pub fn status(args: &AuthStatusArgs) -> Result<()> { let json = args.json; // Resolve space key: BL_SPACE env var takes priority. - // Config load errors (IO, parse) are propagated; only missing space is - // treated as "not logged in". let space_key = if let Ok(s) = std::env::var("BL_SPACE") && !s.is_empty() { @@ -138,6 +189,36 @@ pub fn status(args: &AuthStatusArgs) -> Result<()> { } }; + // OAuth tokens take priority over API key. + if let Ok((tokens, backend)) = secret::get_oauth_tokens(&space_key) { + let auth = AuthDisplay::OAuth { + masked_token: format!( + "{}...", + tokens.access_token.chars().take(4).collect::() + ), + client_id: tokens.client_id.clone(), + masked_client_secret: format!( + "{}...", + tokens.client_secret.chars().take(4).collect::() + ), + backend, + }; + // Build a client that will use the stored OAuth tokens. + let client = match BacklogClient::from_config() { + Ok(c) => c, + Err(e) => { + if json { + println!("{}", serde_json::json!({"error": e.to_string()})); + } else { + println!(" {} {}", "!".red(), e); + } + return Ok(()); + } + }; + return status_with(json, &space_key, &auth, &client); + } + + // Fall back to API key. let (api_key, backend) = match secret::current_api_key(&space_key) { Ok(v) => v, Err(e) => { @@ -150,30 +231,49 @@ pub fn status(args: &AuthStatusArgs) -> Result<()> { } }; + let auth = AuthDisplay::ApiKey { + masked: format!("{}...", api_key.chars().take(4).collect::()), + backend, + }; let client = BacklogClient::new_with( &format!("https://{}.backlog.com/api/v2", space_key), &api_key, )?; - status_with(json, &space_key, &api_key, backend, &client) + status_with(json, &space_key, &auth, &client) } pub fn status_with( json: bool, space_key: &str, - api_key: &str, - backend: Backend, + auth: &AuthDisplay, api: &dyn BacklogApi, ) -> Result<()> { if json { let user = api.get_myself().ok(); - println!("{}", build_status_json(space_key, backend, user)?); + println!("{}", build_status_json(space_key, auth, user)?); return Ok(()); } - let masked = format!("{}...", &api_key[..4.min(api_key.len())]); println!("Space: {}.backlog.com", space_key); - println!(" - API key: {}", masked); - println!(" - Stored in: {}", backend); + match auth { + AuthDisplay::ApiKey { masked, backend } => { + println!(" - Auth method: API key"); + println!(" - API key: {}", masked); + println!(" - Stored in: {}", backend); + } + AuthDisplay::OAuth { + masked_token, + client_id, + masked_client_secret, + backend, + } => { + println!(" - Auth method: OAuth 2.0"); + println!(" - Client ID: {}", client_id); + println!(" - Client Secret: {}", masked_client_secret); + println!(" - Access token: {}", masked_token); + println!(" - Stored in: {}", backend); + } + } match api.get_myself() { Ok(user) => println!(" - Logged in as {} ({})", user.name.green(), user.user_id), @@ -225,12 +325,21 @@ fn remove_space_from_config(cfg: &mut config::Config, key: &str) { } } -fn build_status_json(space_key: &str, backend: Backend, user: Option) -> Result { - let output = serde_json::json!({ - "space_key": space_key, - "stored_in": backend.to_string(), - "user": user, - }); +fn build_status_json(space_key: &str, auth: &AuthDisplay, user: Option) -> Result { + let output = match auth { + AuthDisplay::ApiKey { backend, .. } => serde_json::json!({ + "space_key": space_key, + "auth_method": "api_key", + "stored_in": backend.to_string(), + "user": user, + }), + AuthDisplay::OAuth { client_id, .. } => serde_json::json!({ + "space_key": space_key, + "auth_method": "oauth", + "client_id": client_id, + "user": user, + }), + }; serde_json::to_string_pretty(&output).context("Failed to serialize JSON") } @@ -448,11 +557,29 @@ mod tests { } } + fn api_key_auth(backend: Backend) -> AuthDisplay { + AuthDisplay::ApiKey { + masked: "abcd...".to_string(), + backend, + } + } + + fn oauth_auth() -> AuthDisplay { + AuthDisplay::OAuth { + masked_token: "toke...".to_string(), + client_id: "my-client-id".to_string(), + masked_client_secret: "my-c...".to_string(), + backend: Backend::Keyring, + } + } + #[test] fn build_status_json_with_user() { - let json = build_status_json("mycompany", Backend::Keyring, Some(sample_user())).unwrap(); + let auth = api_key_auth(Backend::Keyring); + let json = build_status_json("mycompany", &auth, Some(sample_user())).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(value["space_key"], "mycompany"); + assert_eq!(value["auth_method"], "api_key"); assert_eq!(value["stored_in"], "System keyring"); assert_eq!(value["user"]["userId"], "john"); assert_eq!(value["user"]["name"], "John Doe"); @@ -460,35 +587,50 @@ mod tests { #[test] fn build_status_json_without_user() { - let json = build_status_json("mycompany", Backend::File, None).unwrap(); + let auth = api_key_auth(Backend::File); + let json = build_status_json("mycompany", &auth, None).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(value["space_key"], "mycompany"); + assert_eq!(value["auth_method"], "api_key"); assert_eq!(value["stored_in"], "Credentials file"); assert!(value["user"].is_null()); } #[test] fn build_status_json_with_env_backend() { - let json = build_status_json("mycompany", Backend::Env, Some(sample_user())).unwrap(); + let auth = api_key_auth(Backend::Env); + let json = build_status_json("mycompany", &auth, Some(sample_user())).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(value["space_key"], "mycompany"); assert_eq!(value["stored_in"], "Environment variable"); assert_eq!(value["user"]["userId"], "john"); } + #[test] + fn build_status_json_with_oauth() { + let auth = oauth_auth(); + let json = build_status_json("mycompany", &auth, Some(sample_user())).unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["space_key"], "mycompany"); + assert_eq!(value["auth_method"], "oauth"); + assert_eq!(value["client_id"], "my-client-id"); + assert_eq!(value["user"]["userId"], "john"); + } + #[test] fn status_with_text_shows_user_info() { let api = MockApi { user: Some(sample_user()), }; - let result = status_with(false, "mycompany", "abcd1234", Backend::Keyring, &api); + let auth = api_key_auth(Backend::Keyring); + let result = status_with(false, "mycompany", &auth, &api); assert!(result.is_ok()); } #[test] fn status_with_text_shows_token_invalid_on_error() { let api = MockApi { user: None }; - let result = status_with(false, "mycompany", "abcd1234", Backend::Keyring, &api); + let auth = api_key_auth(Backend::Keyring); + let result = status_with(false, "mycompany", &auth, &api); assert!(result.is_ok()); } @@ -497,14 +639,36 @@ mod tests { let api = MockApi { user: Some(sample_user()), }; - let result = status_with(true, "mycompany", "abcd1234", Backend::File, &api); + let auth = api_key_auth(Backend::File); + let result = status_with(true, "mycompany", &auth, &api); assert!(result.is_ok()); } #[test] fn status_with_json_null_user_on_api_error() { let api = MockApi { user: None }; - let result = status_with(true, "mycompany", "abcd1234", Backend::File, &api); + let auth = api_key_auth(Backend::File); + let result = status_with(true, "mycompany", &auth, &api); + assert!(result.is_ok()); + } + + #[test] + fn status_with_oauth_text_shows_method() { + let api = MockApi { + user: Some(sample_user()), + }; + let auth = oauth_auth(); + let result = status_with(false, "mycompany", &auth, &api); + assert!(result.is_ok()); + } + + #[test] + fn status_with_oauth_json_includes_client_id() { + let api = MockApi { + user: Some(sample_user()), + }; + let auth = oauth_auth(); + let result = status_with(true, "mycompany", &auth, &api); assert!(result.is_ok()); } diff --git a/src/main.rs b/src/main.rs index dc5b8de..83756ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod api; mod cmd; mod config; +mod oauth; mod secret; use anyhow::Result; @@ -528,6 +529,15 @@ enum AuthCommands { #[arg(long)] no_banner: bool, }, + /// Login via OAuth 2.0 (browser-based) + LoginOauth { + /// Skip the banner + #[arg(long)] + no_banner: bool, + /// Local port for the OAuth callback server (must match the Redirect URI registered in Backlog) + #[arg(long, default_value_t = oauth::DEFAULT_OAUTH_PORT)] + port: u16, + }, /// Show current auth status Status { /// Output as JSON @@ -585,6 +595,7 @@ fn run() -> Result<()> { match command { Commands::Auth { action } => match action { AuthCommands::Login { no_banner } => cmd::auth::login(no_banner), + AuthCommands::LoginOauth { no_banner, port } => cmd::auth::login_oauth(no_banner, port), AuthCommands::Status { json } => cmd::auth::status(&AuthStatusArgs::new(json)), AuthCommands::Logout { space_key, all } => { if all { diff --git a/src/oauth.rs b/src/oauth.rs new file mode 100644 index 0000000..82a032f --- /dev/null +++ b/src/oauth.rs @@ -0,0 +1,411 @@ +use anyhow::{Context, Result}; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::time::Duration; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +pub const DEFAULT_OAUTH_PORT: u16 = 54321; + +/// Tokens and client credentials for OAuth 2.0 authentication. +#[derive(Clone, Serialize, Deserialize)] +pub struct OAuthTokens { + pub client_id: String, + pub client_secret: String, + pub access_token: String, + pub refresh_token: String, +} + +impl std::fmt::Debug for OAuthTokens { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuthTokens") + .field("client_id", &self.client_id) + .field("client_secret", &"") + .field("access_token", &"") + .field("refresh_token", &"") + .finish() + } +} + +/// Response body from Backlog's `/api/v2/oauth2/token` endpoint. +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: String, +} + +/// Run the full OAuth 2.0 authorization code flow. +/// +/// 1. Bind the local callback listener first (fail fast on port conflicts). +/// 2. Open the browser to the Backlog authorization page. +/// 3. Wait for the callback and exchange the code for tokens. +pub fn run_oauth_flow( + space_key: &str, + client_id: &str, + client_secret: &str, + port: u16, +) -> Result { + // Bind before opening the browser so a port conflict is caught immediately. + let listener = TcpListener::bind(format!("127.0.0.1:{port}")).with_context(|| { + format!( + "Failed to bind to port {port}. \ + Is the port already in use? Try a different port with --port." + ) + })?; + + let redirect_uri = format!("http://127.0.0.1:{port}/callback"); + let state = generate_state()?; + let auth_url = format!( + "https://{space_key}.backlog.com/OAuth2AccessRequest.action\ + ?response_type=code\ + &client_id={client_id}\ + &redirect_uri={}\ + &state={state}", + percent_encode(&redirect_uri), + ); + + anstream::eprintln!( + "Opening browser for authorization...\n\ + If the browser does not open, visit:\n {auth_url}" + ); + let _ = open::that(&auth_url); + + anstream::eprintln!( + "Waiting for authorization at http://127.0.0.1:{port}/callback (Ctrl+C to cancel)..." + ); + let code = wait_for_callback(listener, &state)?; + + let tokens = exchange_code(space_key, client_id, client_secret, &code, &redirect_uri)?; + Ok(tokens) +} + +/// Exchange an authorization code for access and refresh tokens. +pub fn exchange_code( + space_key: &str, + client_id: &str, + client_secret: &str, + code: &str, + redirect_uri: &str, +) -> Result { + let token_url = format!("https://{space_key}.backlog.com/api/v2/oauth2/token"); + let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .context("Failed to build HTTP client")?; + + let params = [ + ("grant_type", "authorization_code"), + ("code", code), + ("redirect_uri", redirect_uri), + ("client_id", client_id), + ("client_secret", client_secret), + ]; + + let response = client + .post(&token_url) + .form(¶ms) + .send() + .context("Failed to request OAuth token")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + anyhow::bail!("OAuth token request failed ({}): {}", status, body); + } + + let token_resp: TokenResponse = response.json().context("Failed to parse token response")?; + Ok(OAuthTokens { + client_id: client_id.to_string(), + client_secret: client_secret.to_string(), + access_token: token_resp.access_token, + refresh_token: token_resp.refresh_token, + }) +} + +/// Use a refresh token to obtain a new access token. +pub fn refresh_access_token(space_key: &str, tokens: &OAuthTokens) -> Result { + let token_url = format!("https://{space_key}.backlog.com/api/v2/oauth2/token"); + let client = Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .context("Failed to build HTTP client")?; + + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", tokens.refresh_token.as_str()), + ("client_id", tokens.client_id.as_str()), + ("client_secret", tokens.client_secret.as_str()), + ]; + + let response = client + .post(&token_url) + .form(¶ms) + .send() + .context("Failed to refresh OAuth token")?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + anyhow::bail!("OAuth token refresh failed ({}): {}", status, body); + } + + let token_resp: TokenResponse = response.json().context("Failed to parse token response")?; + Ok(OAuthTokens { + client_id: tokens.client_id.clone(), + client_secret: tokens.client_secret.clone(), + access_token: token_resp.access_token, + refresh_token: token_resp.refresh_token, + }) +} + +/// Block on `listener` until the OAuth callback arrives. +/// Returns the authorization code after verifying the state parameter. +/// +/// Non-callback requests (e.g. browser favicon fetches) are answered with a +/// minimal 200 response and then ignored so that the loop continues waiting. +fn wait_for_callback(listener: TcpListener, expected_state: &str) -> Result { + // Accept connections in a loop until a valid OAuth callback arrives. + // This handles cases where the browser also issues a favicon request. + const MAX_ATTEMPTS: usize = 10; + for _ in 0..MAX_ATTEMPTS { + let (stream, _) = listener + .accept() + .context("Failed to accept OAuth callback")?; + let mut writer = stream.try_clone().context("Failed to clone TCP stream")?; + let reader = BufReader::new(&stream); + + // Read the request line: "GET /callback?code=...&state=... HTTP/1.1" + let request_line = match reader.lines().next() { + Some(Ok(line)) => line, + _ => continue, + }; + + // Second token is the path+query string. + let path = match request_line.split_whitespace().nth(1) { + Some(p) => p.to_string(), + None => continue, + }; + + // Skip requests that are not the OAuth callback (e.g. /favicon.ico). + if !path.starts_with("/callback") { + send_html_response(&mut writer, 200, ""); + continue; + } + + // Handle OAuth denial or error response from the provider. + if let Some((error, description)) = parse_error_params(&path) { + let msg = if description.is_empty() { + error.clone() + } else { + format!("{error}: {description}") + }; + send_html_response( + &mut writer, + 400, + &format!("

Authorization denied

{msg}

"), + ); + anyhow::bail!("Authorization denied by provider: {msg}"); + } + + let (code, state) = parse_callback_params(&path)?; + + if state != expected_state { + send_html_response( + &mut writer, + 400, + "

Authorization failed

State mismatch. Please try again.

", + ); + anyhow::bail!("OAuth state mismatch — possible CSRF attempt"); + } + + send_html_response( + &mut writer, + 200, + "

Authorization successful!

\ +

You can close this tab and return to the terminal.

", + ); + + return Ok(code); + } + + anyhow::bail!("OAuth callback not received after {MAX_ATTEMPTS} connection attempts") +} + +fn parse_callback_params(path: &str) -> Result<(String, String)> { + let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + + let mut code = None; + let mut state = None; + + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') { + match k { + "code" => code = Some(percent_decode(v)), + "state" => state = Some(percent_decode(v)), + _ => {} + } + } + } + + let code = code.context("OAuth callback: 'code' parameter missing")?; + let state = state.context("OAuth callback: 'state' parameter missing")?; + Ok((code, state)) +} + +/// Parse `error` and `error_description` from a callback query string. +/// Returns `Some((error, description))` when an `error` parameter is present. +fn parse_error_params(path: &str) -> Option<(String, String)> { + let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + let mut error = None; + let mut description = String::new(); + for pair in query.split('&') { + if let Some((k, v)) = pair.split_once('=') { + match k { + "error" => error = Some(percent_decode(v)), + "error_description" => description = percent_decode(v), + _ => {} + } + } + } + error.map(|e| (e, description)) +} + +fn send_html_response(stream: &mut impl Write, status: u16, body: &str) { + let reason = match status { + 200 => "OK", + _ => "Bad Request", + }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\n\ + Content-Type: text/html; charset=utf-8\r\n\ + Content-Length: {}\r\n\ + Connection: close\r\n\r\n\ + {}", + body.len(), + body + ); + let _ = stream.write_all(response.as_bytes()); +} + +/// Generate an opaque state value for CSRF protection using OS CSPRNG. +fn generate_state() -> Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes) + .map_err(|e| anyhow::anyhow!("Failed to generate random state: {e}"))?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn percent_decode(s: &str) -> String { + let mut bytes: Vec = Vec::with_capacity(s.len()); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '%' { + let h1 = chars.next().unwrap_or('0'); + let h2 = chars.next().unwrap_or('0'); + if let Ok(byte) = u8::from_str_radix(&format!("{h1}{h2}"), 16) { + bytes.push(byte); + } + } else { + let mut buf = [0u8; 4]; + bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + String::from_utf8_lossy(&bytes).into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percent_encode_plain_ascii() { + assert_eq!(percent_encode("hello"), "hello"); + } + + #[test] + fn percent_encode_special_chars() { + let encoded = percent_encode("http://localhost:54321/callback"); + assert!(encoded.contains("%3A")); // ':' + assert!(encoded.contains("%2F")); // '/' + } + + #[test] + fn percent_decode_roundtrip() { + let original = "http://localhost:54321/callback"; + assert_eq!(percent_decode(&percent_encode(original)), original); + } + + #[test] + fn parse_callback_params_success() { + let (code, state) = parse_callback_params("/callback?code=abc123&state=deadbeef").unwrap(); + assert_eq!(code, "abc123"); + assert_eq!(state, "deadbeef"); + } + + #[test] + fn parse_callback_params_missing_code() { + assert!(parse_callback_params("/callback?state=deadbeef").is_err()); + } + + #[test] + fn parse_callback_params_missing_state() { + assert!(parse_callback_params("/callback?code=abc123").is_err()); + } + + #[test] + fn parse_error_params_with_description() { + let result = + parse_error_params("/callback?error=access_denied&error_description=User+denied"); + assert!(result.is_some()); + let (error, desc) = result.unwrap(); + assert_eq!(error, "access_denied"); + assert_eq!(desc, "User+denied"); + } + + #[test] + fn parse_error_params_without_description() { + let result = parse_error_params("/callback?error=access_denied"); + assert!(result.is_some()); + let (error, desc) = result.unwrap(); + assert_eq!(error, "access_denied"); + assert!(desc.is_empty()); + } + + #[test] + fn parse_error_params_no_error() { + assert!(parse_error_params("/callback?code=abc&state=xyz").is_none()); + } + + #[test] + fn generate_state_is_nonempty() { + let s = generate_state().unwrap(); + assert!(!s.is_empty()); + } + + #[test] + fn generate_state_two_calls_differ() { + let s1 = generate_state().unwrap(); + let s2 = generate_state().unwrap(); + assert!(!s1.is_empty()); + assert!(!s2.is_empty()); + assert_ne!(s1, s2); + } +} diff --git a/src/secret.rs b/src/secret.rs index 75b129b..6aa17b1 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -2,7 +2,10 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use crate::oauth::OAuthTokens; + const SERVICE: &str = "bl"; +const OAUTH_SERVICE: &str = "bl-oauth"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Backend { @@ -172,6 +175,113 @@ pub fn remove_credentials_file() -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// OAuth token storage +// +// Tokens are serialized as JSON and stored in the keyring under the service +// name "bl-oauth" with the space key as the username. A file fallback +// (`oauth_tokens.toml`) is used when the keyring is unavailable. +// --------------------------------------------------------------------------- + +pub fn get_oauth_tokens(space_key: &str) -> Result<(OAuthTokens, Backend)> { + // Keyring first + if let Ok(entry) = keyring::Entry::new(OAUTH_SERVICE, space_key) + && let Ok(json) = entry.get_password() + { + return serde_json::from_str(&json) + .context("Failed to deserialize OAuth tokens from keyring") + .map(|tokens| (tokens, Backend::Keyring)); + } + // File fallback + oauth_file_get(space_key) +} + +pub fn set_oauth_tokens(space_key: &str, tokens: &OAuthTokens) -> Result<()> { + let json = serde_json::to_string(tokens).context("Failed to serialize OAuth tokens")?; + if let Ok(entry) = keyring::Entry::new(OAUTH_SERVICE, space_key) + && entry.set_password(&json).is_ok() + { + return Ok(()); + } + oauth_file_set(space_key, tokens) +} + +pub fn delete_oauth_tokens(space_key: &str) -> Result<()> { + if let Ok(entry) = keyring::Entry::new(OAUTH_SERVICE, space_key) { + match entry.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) => return Err(e).context("Failed to delete OAuth tokens from keyring"), + } + } + oauth_file_delete(space_key) +} + +// --- file fallback helpers -------------------------------------------------- + +fn oauth_file_path() -> Result { + let config_dir = dirs::config_dir().context("Could not determine config directory")?; + Ok(config_dir.join("bl").join("oauth_tokens.toml")) +} + +#[derive(Debug, Serialize, Deserialize, Default)] +struct OAuthFile { + #[serde(default)] + tokens: std::collections::HashMap, +} + +fn oauth_file_load() -> Result { + let path = oauth_file_path()?; + if !path.exists() { + return Ok(OAuthFile::default()); + } + let contents = std::fs::read_to_string(&path) + .with_context(|| format!("Failed to read {}", path.display()))?; + toml::from_str(&contents).context("Failed to parse oauth_tokens.toml") +} + +fn oauth_file_save(file: &OAuthFile) -> Result<()> { + let path = oauth_file_path()?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + let contents = toml::to_string_pretty(file).context("Failed to serialize oauth_tokens.toml")?; + std::fs::write(&path, &contents) + .with_context(|| format!("Failed to write {}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .context("Failed to set oauth_tokens.toml permissions")?; + } + Ok(()) +} + +fn oauth_file_get(space_key: &str) -> Result<(OAuthTokens, Backend)> { + let file = oauth_file_load()?; + file.tokens + .get(space_key) + .cloned() + .with_context(|| format!("OAuth tokens not found for space '{space_key}'")) + .map(|tokens| (tokens, Backend::File)) +} + +fn oauth_file_set(space_key: &str, tokens: &OAuthTokens) -> Result<()> { + let mut file = oauth_file_load()?; + file.tokens.insert(space_key.to_string(), tokens.clone()); + oauth_file_save(&file) +} + +fn oauth_file_delete(space_key: &str) -> Result<()> { + let path = oauth_file_path()?; + if !path.exists() { + return Ok(()); + } + let mut file = oauth_file_load()?; + file.tokens.remove(space_key); + oauth_file_save(&file) +} + fn set_impl( space_key: &str, api_key: &str,