Skip to content

feat: add OAuth 2.0 authentication via bl auth login oauth#23

Merged
23prime merged 16 commits intomainfrom
feature/oauth-login
Mar 9, 2026
Merged

feat: add OAuth 2.0 authentication via bl auth login oauth#23
23prime merged 16 commits intomainfrom
feature/oauth-login

Conversation

@23prime
Copy link
Owner

@23prime 23prime commented Mar 9, 2026

Checklist

  • Target branch is main
  • Status checks are passing

Summary

  • Add bl auth login oauth command for browser-based OAuth 2.0 authentication
  • Users register their own OAuth app in Backlog (Confidential Client); no bundled credentials
  • Fixed callback port (default 54321, overridable via --port) to match Backlog's exact Redirect URI requirement
  • Access tokens are automatically refreshed on 401 using the stored refresh token

Reason for change

Backlog API keys are static and long-lived. OAuth 2.0 provides time-limited tokens with automatic refresh, which is safer for interactive use.

Changes

New files

  • src/oauth.rs — OAuth 2.0 authorization code flow, local TcpListener callback server, token refresh, percent encode/decode helpers

Modified files

  • Cargo.toml / Cargo.lock — add open = "5" (browser launch), getrandom = "0.3" (CSPRNG state)
  • src/secret.rsget_oauth_tokens / set_oauth_tokens / delete_oauth_tokens with keyring-first and file fallback (oauth_tokens.toml, mode 0600)
  • src/api/mod.rsAuthMethod enum (ApiKey / Bearer), RefCell-based token refresh, execute() closure retry pattern on 401
  • src/cmd/auth.rslogin_oauth(), AuthDisplay enum, updated status / status_with / build_status_json / logout / logout_all
  • src/main.rsmod oauth, AuthCommands::LoginOauth { no_banner, port }
  • docs/user-guide.md / docs/user-guide.ja.md — document the new command and OAuth token storage locations

Notes

  • bl auth login oauth requires users to register their own OAuth application in Backlog space settings (Backlog only supports space-level registration, so credentials cannot be bundled)
  • OAuth tokens take priority over API keys when both are stored for the same space
  • The TCP callback server loops up to 10 connections to skip non-callback requests (e.g. browser favicon fetches)

Copilot AI review requested due to automatic review settings March 9, 2026 07:23
@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7de00149-16c7-457a-bb15-a4a2c61bad46

📥 Commits

Reviewing files that changed from the base of the PR and between 914b725 and 7401604.

📒 Files selected for processing (5)
  • docs/user-guide.ja.md
  • docs/user-guide.md
  • src/api/mod.rs
  • src/cmd/auth.rs
  • src/secret.rs

📝 Walkthrough

Walkthrough

Adds OAuth 2.0 authorization-code flow (with refresh), integrates OAuth into the API client via an AuthMethod enum, persists OAuth tokens in keyring + file fallback, adds CLI login-oauth command and wiring, updates secret storage, docs, and dependencies.

Changes

Cohort / File(s) Summary
Dictionary & Dependencies
\.cspell/dicts/project.txt, Cargo.toml
Add spellcheck terms (getrandom, CSPRNG, subsec) and add dependencies open = "5", getrandom = "0.3".
Documentation
docs/user-guide.md, docs/user-guide.ja.md
Document OAuth 2.0 login flow, bl auth login-oauth usage, status/output examples, and storage/backends including oauth_tokens.toml fallback.
OAuth Core
src/oauth.rs
New OAuth implementation: local callback listener, browser launch, state generation, code exchange, token refresh, helpers (percent encode/decode), tests, OAuthTokens and DEFAULT_OAUTH_PORT.
API Client Auth Layer
src/api/mod.rs
Replace api_key: String with pub(crate) auth: AuthMethod; add helpers apply_auth, execute, try_refresh, finish_response; prefer Bearer when available and retry after refresh.
CLI Auth Commands & Wiring
src/cmd/auth.rs, src/main.rs
Add login_oauth command and AuthCommands::LoginOauth; introduce AuthDisplay, update status_with/build_status_json, logout now removes OAuth tokens, and wire CLI to oauth module.
Token Persistence
src/secret.rs
Add OAuth token storage via keyring service bl-oauth and file fallback oauth_tokens.toml; new APIs get_oauth_tokens, set_oauth_tokens, delete_oauth_tokens and internal file helpers.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant CLI as CLI (login_oauth)
    participant Browser
    participant Local as Local Callback Server
    participant Backlog as Backlog OAuth Endpoint
    participant Storage as Keyring/File Storage

    User->>CLI: trigger login_oauth(space, client_id, client_secret)
    CLI->>Local: start callback listener (port)
    CLI->>Backlog: build authorization URL
    CLI->>Browser: open authorization URL
    Browser->>Backlog: user authenticates & authorizes
    Backlog->>Local: redirect to /callback with code & state
    Local->>CLI: deliver authorization code
    CLI->>Backlog: POST exchange code for tokens
    Backlog->>CLI: return access_token & refresh_token
    CLI->>Storage: persist OAuth tokens
    Storage->>CLI: confirm stored
    CLI->>User: display success
Loading
sequenceDiagram
    participant App
    participant Client as BacklogClient
    participant API as Backlog API
    participant Storage as Token Storage

    App->>Client: make API request
    Client->>Client: inspect AuthMethod (Bearer or ApiKey)
    alt Bearer
        Client->>API: add Authorization: Bearer <token>
        API-->>Client: 401 Unauthorized
        Client->>Storage: read refresh_token
        Client->>API: POST token refresh
        API->>Client: return new access_token
        Client->>Storage: update tokens
        Client->>API: retry request with new Bearer
    else ApiKey
        Client->>API: add apiKey query parameter
    end
    API->>Client: response
    Client->>App: parsed result or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped to open browser doors,
a tiny state twirled on my paws,
codes returned, tokens tucked away,
refreshes hum and retries play—
I nibble bugs and celebrate with carrots 🥕🔐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 63.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature being added: OAuth 2.0 authentication via a new 'bl auth login oauth' command, which aligns with the substantial changes across multiple files.
Description check ✅ Passed The description is directly related to the changeset, providing clear context about OAuth 2.0 authentication implementation, reason for change, and comprehensive details about new and modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/oauth-login
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch feature/oauth-login

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds browser-based OAuth 2.0 authentication to the Backlog CLI, including token storage and automatic refresh, as an alternative to long-lived API keys.

Changes:

  • Introduces an OAuth 2.0 authorization-code flow with a localhost callback listener and token refresh logic.
  • Extends credential storage to persist OAuth tokens (keyring-first, file fallback).
  • Updates the API client to support API key or Bearer auth and retry once on 401 after refresh; updates auth commands/status output and user guides.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/oauth.rs Implements OAuth flow (browser open, local callback server, token exchange/refresh, encode/decode helpers).
src/secret.rs Adds keyring + oauth_tokens.toml fallback storage for OAuth tokens.
src/api/mod.rs Adds AuthMethod (API key vs Bearer), applies auth to requests, retries once on 401 with refresh.
src/cmd/auth.rs Adds login_oauth, updates logout to delete OAuth tokens, and extends auth status to show OAuth.
src/main.rs Registers a new auth subcommand and wires it to cmd::auth::login_oauth.
docs/user-guide.md Documents OAuth login and token storage locations; updates command table.
docs/user-guide.ja.md Japanese documentation for OAuth login and storage locations.
Cargo.toml Adds dependencies for browser launch and RNG (open, getrandom).
Cargo.lock Locks new dependencies and transitive crates.
.cspell/dicts/project.txt Adds new terms for spell-check.
Comments suppressed due to low confidence (1)

src/cmd/auth.rs:104

  • logout_all() removes config.toml and credentials.toml, but it does not remove the OAuth token fallback file (oauth_tokens.toml). Since this PR introduces that file and logout_all claims “Config files removed.”, consider also deleting oauth_tokens.toml (e.g., add a secret::remove_oauth_tokens_file() helper and call it here) so logout-all fully cleans up stored auth material.
pub fn logout_all() -> Result<()> {
    let cfg = config::load()?;

    for key in &cfg.spaces {
        secret::delete(key)?;
        secret::delete_oauth_tokens(key)?;
    }

    config::remove_config_file()?;
    secret::remove_credentials_file()?;

    println!(
        "{}",
        "Logged out from all spaces. Config files removed.".yellow()
    );
    Ok(())

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/cmd/auth.rs (1)

89-99: ⚠️ Potential issue | 🟠 Major

logout --all still leaves the OAuth fallback file behind.

After the per-space deletes, this only removes config.toml and credentials.toml. If OAuth tokens were stored in oauth_tokens.toml, bl auth logout --all / uninstall --purge leaves that file on disk.

🧹 Minimal fix
     config::remove_config_file()?;
     secret::remove_credentials_file()?;
+    secret::remove_oauth_tokens_file()?;

Add the matching helper in src/secret.rs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cmd/auth.rs` around lines 89 - 99, The logout_all flow currently deletes
per-space secrets via secret::delete and secret::delete_oauth_tokens but never
removes the global oauth_tokens file; add a helper in secret.rs (e.g.,
remove_oauth_tokens_file or remove_oauth_tokens) that deletes the
oauth_tokens.toml file and call it from logout_all alongside
secret::remove_credentials_file (after the per-space loop) so the global OAuth
fallback file is removed during `bl auth logout --all` / `--purge`.
🧹 Nitpick comments (1)
src/oauth.rs (1)

262-279: Add a UTF-8 regression test for the custom decoder.

The current tests only cover ASCII inputs, but Lines 275-279 contain a separate multibyte path. A single non-ASCII round-trip case would pin that behavior down and guard against regressions in percent_decode.

Also applies to: 298-335

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/oauth.rs` around lines 262 - 279, Add a UTF-8 regression test that
exercises the multibyte branch of percent_decode by percent-encoding a non-ASCII
character (e.g., a multi-byte UTF-8 glyph), feeding it into percent_decode and
asserting the decoded result equals the original Unicode string; do the same for
the other decoder path referenced in the diff (the multibyte branch around the
later decoder code) to ensure round-trip decoding of multibyte UTF-8 is
preserved. Ensure the test uses a literal non-ASCII character (not ASCII-only)
and covers both '%' hex-decoding and the fallback multibyte path in
percent_decode so future changes cannot regress UTF-8 handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/api/mod.rs`:
- Around line 297-304: The match arm swallowing refresh errors should propagate
them and also surface failures when persisting tokens: when calling
crate::oauth::refresh_access_token(...) do not convert Err(_) into Ok(false) —
return or map the actual error so callers can handle it; after obtaining
new_tokens, check the Result from crate::secret::set_oauth_tokens(...) and
propagate or return that error if it fails (do not silently ignore it), while
still updating access_token.borrow_mut() and refresh_token.borrow_mut(); in
short, replace the Err(_) => Ok(false) behavior with returning the real refresh
error, and handle/publish errors from set_oauth_tokens().

In `@src/cmd/auth.rs`:
- Around line 42-48: Update the user prompt in auth.rs so it directs users to
register a space-level app instead of a personal app: change the printed path
from "Personal settings → OAuth 2.0 Applications → Add" to "Space settings →
Apps → Add (create a Confidential Client)" and keep the existing Redirect URI
print (the format!("http://localhost:{port}/callback").bold()) intact so the
correct localhost callback is shown.

In `@src/main.rs`:
- Around line 532-540: Refactor the flat AuthCommands::LoginOauth enum variant
into a nested subcommand structure so the CLI parses as "bl auth login oauth":
create a LoginArgs struct and change the AuthCommands enum to have a
Login(LoginArgs) variant; inside LoginArgs add a field like
#[command(subcommand)] pub cmd: LoginCmd; define a LoginCmd enum with an
Oauth(OauthArgs) variant (moving the existing LoginOauth fields into OauthArgs).
Update any code that matched on AuthCommands::LoginOauth to instead match
AuthCommands::Login(cmd) -> LoginCmd::Oauth(args) and preserve the existing
fields (no_banner, port) in the new OauthArgs type.

In `@src/oauth.rs`:
- Around line 51-56: The code opens the auth URL (open::that) before starting
the local callback listener, causing late discovery of port-in-use and a race;
change the flow so the callback listener is bound/started first (invoke or spawn
the listener created by wait_for_callback or its underlying bind function and
confirm bind success) and only after successful binding call
open::that(&auth_url); also apply the same ordering where the bind occurs around
the block referenced at lines 145-150 so any bind errors are returned before
launching the browser, and ensure proper error propagation from
wait_for_callback or the listener-start function.
- Line 36: The redirect URI is built with "localhost" in the redirect_uri
variable but the HTTP listener is bound to "127.0.0.1", causing a mismatch;
change the code so both use the same host string: either build redirect_uri with
"127.0.0.1" (update the redirect_uri =
format!("http://127.0.0.1:{port}/callback")) or change the listener bind to
"localhost" (update the TcpListener/Server bind call) — ensure the same host
constant/string is reused in both the redirect_uri construction and the listener
bind to keep them identical.
- Around line 243-246: The helper generate_state currently panics on random
generation; change its signature to return anyhow::Result<String> (or
Result<String, anyhow::Error>), replace expect("...") with the ? operator (or
.context("Failed to generate random state")?) to propagate the getrandom::Error,
and keep the hex-formatting logic returning Ok(string). Then update
run_oauth_flow to call generate_state()? and propagate its error (or handle it)
so the whole module uses anyhow-based error propagation instead of aborting.
- Around line 10-15: The derived Debug for OAuthTokens and TokenResponse exposes
sensitive fields; replace the automatic derive by implementing std::fmt::Debug
for both OAuthTokens and TokenResponse so that non-sensitive fields (e.g.,
client_id) are shown but client_secret, access_token, refresh_token (and any
other secret fields) are printed as "<redacted>" (use
f.debug_struct(...).field(...).finish() pattern) to avoid leaking credentials in
logs or error messages.

In `@src/secret.rs`:
- Around line 208-213: The current delete_oauth_tokens function swallows all
keyring delete errors; change it to call keyring::Entry::new and then match the
result of entry.delete_credential(), ignoring only keyring::Error::NoEntry and
otherwise returning an Err (propagate the failure) before proceeding to
oauth_file_delete(space_key); keep calling oauth_file_delete only when the
keyring delete either succeeded or failed with NoEntry. Use the concrete symbols
delete_oauth_tokens, keyring::Entry::new, entry.delete_credential,
keyring::Error::NoEntry, and oauth_file_delete to locate and update the logic so
keyring failures are surfaced.
- Around line 264-267: The current oauth_file_set uses
oauth_file_load().unwrap_or_default(), which masks parse/permission errors and
can overwrite oauth_tokens.toml; change oauth_file_set to propagate the load
error instead of defaulting—call oauth_file_load() with ? (or otherwise return
the Err) to bail on load failures, then insert into file.tokens and call
oauth_file_save(&file) as before so we never overwrite the file after a failed
load; reference functions oauth_file_set, oauth_file_load, and oauth_file_save.

---

Outside diff comments:
In `@src/cmd/auth.rs`:
- Around line 89-99: The logout_all flow currently deletes per-space secrets via
secret::delete and secret::delete_oauth_tokens but never removes the global
oauth_tokens file; add a helper in secret.rs (e.g., remove_oauth_tokens_file or
remove_oauth_tokens) that deletes the oauth_tokens.toml file and call it from
logout_all alongside secret::remove_credentials_file (after the per-space loop)
so the global OAuth fallback file is removed during `bl auth logout --all` /
`--purge`.

---

Nitpick comments:
In `@src/oauth.rs`:
- Around line 262-279: Add a UTF-8 regression test that exercises the multibyte
branch of percent_decode by percent-encoding a non-ASCII character (e.g., a
multi-byte UTF-8 glyph), feeding it into percent_decode and asserting the
decoded result equals the original Unicode string; do the same for the other
decoder path referenced in the diff (the multibyte branch around the later
decoder code) to ensure round-trip decoding of multibyte UTF-8 is preserved.
Ensure the test uses a literal non-ASCII character (not ASCII-only) and covers
both '%' hex-decoding and the fallback multibyte path in percent_decode so
future changes cannot regress UTF-8 handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9cd1cc32-d7dd-468b-a8bb-802d4c91c46f

📥 Commits

Reviewing files that changed from the base of the PR and between c621425 and 8605fe8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • .cspell/dicts/project.txt
  • Cargo.toml
  • docs/user-guide.ja.md
  • docs/user-guide.md
  • src/api/mod.rs
  • src/cmd/auth.rs
  • src/main.rs
  • src/oauth.rs
  • src/secret.rs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/user-guide.ja.md`:
- Around line 234-235: Update the docs entry for the `bl auth login-oauth`
command to document the `--no-banner` option as well as `--port`; mention that
the command maps to AuthCommands::LoginOauth and the CLI handler function
login_oauth(no_banner, port) so users know `--no-banner` is accepted (e.g.,
"ブラウザベースの OAuth 2.0 で認証。`--port <port>`(デフォルト: 54321)および `--no-banner` を使用可能").
- Around line 126-134: The user guide's OAuth registration path conflicts with
the CLI behavior; update either the documentation or the CLI so both show the
same Backlog UI path. Locate the doc section that currently says "スペース設定 → アプリ"
and the login_oauth() function in src/cmd/auth.rs (which points users to
"Personal settings → OAuth 2.0 Applications → Add") and make them consistent:
either change the doc text to match login_oauth() or modify login_oauth() to
point to "スペース設定 → アプリ" (and update any UI labels/messages accordingly), then
run a quick manual check to confirm the displayed path in the CLI and the doc
now match.
- Around line 848-850: The troubleshooting section "キーリングが利用できない" currently only
documents restoring credentials from `~/.config/bl/credentials.toml`; add
equivalent instructions for OAuth users describing
`~/.config/bl/oauth_tokens.toml` as the OAuth fallback (mode 0600), how to
create/populate it with tokens, set permissions (e.g., chmod 600), and explain
that the system keyring is preferred but the oauth_tokens file will be used if
the keyring is unavailable; apply the same edits to the corresponding secondary
location noted (also applies to lines 857-859) so both places consistently
document the OAuth fallback file and recovery steps.

In `@docs/user-guide.md`:
- Line 238: The docs are missing the --no-banner option for the LoginOauth
command; update the `bl auth login-oauth` row in the command table to mention
`--no-banner` (in addition to `--port <port>`) since the command is implemented
as AuthCommands::LoginOauth { no_banner, port }, i.e., document that you can
pass `--no-banner` to suppress the banner (alongside the existing note about
overriding the default callback port 54321).
- Around line 853-855: Update the "Keyring is unavailable" troubleshooting
paragraph in the "Keyring is unavailable" section to mention the OAuth fallback
file alongside the credentials fallback: include
`~/.config/bl/oauth_tokens.toml` (mode 0600) in the recovery steps and examples
where `~/.config/bl/credentials.toml` is currently referenced so both API key
and OAuth token fallbacks are documented; ensure the wording matches the table
entries for `~/.config/bl/credentials.toml` and
`~/.config/bl/oauth_tokens.toml`.
- Around line 130-138: The docs' Step 1 currently points to "Space settings →
Apps" but the CLI command bl auth login-oauth prints "Personal settings → OAuth
2.0 Applications → Add"; update the user-guide.md Step 1 text to match the CLI
output (use "Personal settings → OAuth 2.0 Applications → Add") so the
instructions align with the bl auth login-oauth flow, or alternatively change
the literal help/output in the login-oauth implementation to match the
docs—locate the CLI message in the auth command (the string printed by bl auth
login-oauth) and make the two messages identical.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 95909ef5-c93e-4ee0-8bf4-7091497edf1d

📥 Commits

Reviewing files that changed from the base of the PR and between 8605fe8 and 8d03d96.

📒 Files selected for processing (2)
  • docs/user-guide.ja.md
  • docs/user-guide.md

23prime added 5 commits March 9, 2026 17:29
Addresses review comment: "generate_state() uses expect(...), which will panic the entire CLI"
Addresses review comment: "directs users to Personal settings instead of Space settings → Apps"
Addresses review comment: "match arm swallowing refresh errors should propagate them"
…auth_file_set

Addresses review comments: keyring errors swallowed on logout; unwrap_or_default can erase other spaces' tokens
…xpand keyring fallback

Addresses review comments: localhost/127.0.0.1 mismatch in docs, missing --no-banner for login-oauth, missing OAuth fallback in troubleshooting
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/oauth.rs (1)

55-64: ⚠️ Potential issue | 🟠 Major

Unify the registered callback host with the actual redirect URI.

run_oauth_flow now sends http://127.0.0.1:{port}/callback, but src/cmd/auth.rs:45-49 still tells users to register http://localhost:{port}/callback. The PR notes that Backlog requires an exact Redirect URI, so a user who follows the CLI prompt will register the wrong URI and this auth request will be rejected. Please drive both strings from one shared constant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/oauth.rs` around lines 55 - 64, The redirect URI used in run_oauth_flow
is hardcoded to "http://127.0.0.1:{port}/callback" while the CLI help text tells
users to register "http://localhost:{port}/callback"; create a single shared
constant (e.g., CALLBACK_HOST or CALLBACK_URI_TEMPLATE) and use it in
run_oauth_flow (where redirect_uri is formed and percent_encoded) and in the CLI
prompt/registration message so both the generated auth_url and the text shown to
users come from the same source; update references in the functions
run_oauth_flow and the auth command’s help/print routine to construct the final
URI by interpolating the port into the shared template.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/oauth.rs`:
- Around line 192-193: parse_callback_params currently ignores the OAuth error
query param so wait_for_callback later fails with a misleading "code parameter
missing" and no browser response; update parse_callback_params to parse and
return an error variant (or include Option<error>) when the query contains error
and state, and update wait_for_callback (and the callback HTTP handler logic
referenced in the same area) to detect that error case, write an appropriate
HTML response to the browser (e.g., "consent denied" or the provider error) and
return a clear Err with the provider error/state instead of treating it as a
missing code; reference the functions parse_callback_params and
wait_for_callback to locate where to add error parsing, response rendering, and
error propagation.
- Around line 90-92: The reqwest blocking Client builders use infinite timeouts;
update each client construction to set finite timeouts by adding
.timeout(Duration::from_secs(30)) and .connect_timeout(Duration::from_secs(10))
on the Client::builder() before build(), importing std::time::Duration as
needed; apply this change where you create the clients for OAuth token exchange
and refresh (the functions handling token exchange/refresh in oauth.rs) and in
the BacklogClient builders (BacklogClient::from_config and
BacklogClient::new_with) so all four client builders use these timeouts.

---

Duplicate comments:
In `@src/oauth.rs`:
- Around line 55-64: The redirect URI used in run_oauth_flow is hardcoded to
"http://127.0.0.1:{port}/callback" while the CLI help text tells users to
register "http://localhost:{port}/callback"; create a single shared constant
(e.g., CALLBACK_HOST or CALLBACK_URI_TEMPLATE) and use it in run_oauth_flow
(where redirect_uri is formed and percent_encoded) and in the CLI
prompt/registration message so both the generated auth_url and the text shown to
users come from the same source; update references in the functions
run_oauth_flow and the auth command’s help/print routine to construct the final
URI by interpolating the port into the shared template.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 66435b9c-fc99-414c-87c3-53fe22f17d68

📥 Commits

Reviewing files that changed from the base of the PR and between 8d03d96 and d32b14e.

📒 Files selected for processing (1)
  • src/oauth.rs

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/cmd/auth.rs (1)

190-212: Same silent error masking pattern as from_config.

This mirrors the if let Ok() pattern in src/api/mod.rs:237. For consistency, if you address the suggestion there, apply the same approach here to surface OAuth load errors vs "not found".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/cmd/auth.rs` around lines 190 - 212, The current if let Ok(tokens) =
secret::get_oauth_tokens(&space_key) silently ignores errors when loading OAuth
tokens; change this to match on secret::get_oauth_tokens(&space_key) so you can
distinguish a "not found" case from real load errors and log/return the latter
(similar to how you will update src/api/mod.rs). In the match arm where you get
Err(e), surface the error (use json output or colored message like the
BacklogClient::from_config() error handling), and only proceed to construct
AuthDisplay::OAuth, create BacklogClient via BacklogClient::from_config(), and
call status_with(...) when tokens were loaded successfully. Ensure you reference
get_oauth_tokens, AuthDisplay::OAuth, BacklogClient::from_config, and
status_with when making the changes.
src/oauth.rs (1)

316-332: percent_decode does not decode + as space.

RFC 3986 percent-encoding doesn't treat + specially, but form-encoded data (RFC 1866 / application/x-www-form-urlencoded) uses + for spaces. OAuth error descriptions may arrive form-encoded. The test at Line 380 asserts "User+denied" rather than "User denied", which suggests this is intentional — but users will see the raw + in error messages.

Consider decoding + to space for better UX in error messages:

♻️ Optional fix
 fn percent_decode(s: &str) -> String {
     let mut bytes: Vec<u8> = 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 if c == '+' {
+            bytes.push(b' ');
         } else {
             let mut buf = [0u8; 4];
             bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
         }
     }
     String::from_utf8_lossy(&bytes).into_owned()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/oauth.rs` around lines 316 - 332, The percent_decode function currently
ignores '+' and leaves it as literal plus; update percent_decode to treat '+' as
a space by handling the '+' case in the main loop (e.g., push ASCII 0x20 or
extend bytes with b' '), while keeping existing percent-decoding logic for '%'
and UTF-8 encoding for other chars; modify any tests expecting "User+denied" if
they should expect "User denied". Ensure the change is made inside the
percent_decode function so all callers (OAuth error handling) receive decoded
spaces.
src/api/mod.rs (1)

236-249: OAuth configuration errors are silently masked by fallback to API key authentication.

The if let Ok() pattern on line 237 swallows all errors from get_oauth_tokens, including parse errors ("Failed to parse oauth_tokens.toml") and read errors ("Failed to read {path}"). This means if oauth_tokens.toml is corrupted or inaccessible, the client will silently fall back to API key auth without the user's knowledge. Only "tokens not found for this space" errors are intentionally caught.

To distinguish "no tokens configured" from "tokens exist but failed to load", check the error message:

♻️ Suggested approach
-        if let Ok(tokens) = crate::secret::get_oauth_tokens(&space_key) {
+        match crate::secret::get_oauth_tokens(&space_key) {
+            Ok(tokens) => {
+                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,
+                    },
+                });
+            }
+            Err(e) if e.to_string().contains("not found") => {
+                // No OAuth tokens configured for this space, fall through to API key
+            }
+            Err(e) => {
+                // OAuth tokens exist but failed to load - surface the error
+                return Err(e);
+            }
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/mod.rs` around lines 236 - 249, The current if-let
Ok(get_oauth_tokens(&space_key)) swallows all errors and silently falls back to
API key; instead match on crate::secret::get_oauth_tokens(&space_key) and only
fall back when the error indicates "tokens not found for this space" (or the
specific NotFound variant/message your secret module returns); for other errors
(parse/read/corruption) propagate or return the error (or wrap with context) so
the failure is visible. Update the match branch that constructs
AuthMethod::Bearer on Ok(tokens), handle Err(e) by checking e.to_string() or
pattern-matching the error type from get_oauth_tokens, and return Err(e) (or
log+return) for non-"not found" errors while only proceeding to API-key logic
when the error indicates missing tokens for space_key.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/api/mod.rs`:
- Around line 236-249: The current if-let Ok(get_oauth_tokens(&space_key))
swallows all errors and silently falls back to API key; instead match on
crate::secret::get_oauth_tokens(&space_key) and only fall back when the error
indicates "tokens not found for this space" (or the specific NotFound
variant/message your secret module returns); for other errors
(parse/read/corruption) propagate or return the error (or wrap with context) so
the failure is visible. Update the match branch that constructs
AuthMethod::Bearer on Ok(tokens), handle Err(e) by checking e.to_string() or
pattern-matching the error type from get_oauth_tokens, and return Err(e) (or
log+return) for non-"not found" errors while only proceeding to API-key logic
when the error indicates missing tokens for space_key.

In `@src/cmd/auth.rs`:
- Around line 190-212: The current if let Ok(tokens) =
secret::get_oauth_tokens(&space_key) silently ignores errors when loading OAuth
tokens; change this to match on secret::get_oauth_tokens(&space_key) so you can
distinguish a "not found" case from real load errors and log/return the latter
(similar to how you will update src/api/mod.rs). In the match arm where you get
Err(e), surface the error (use json output or colored message like the
BacklogClient::from_config() error handling), and only proceed to construct
AuthDisplay::OAuth, create BacklogClient via BacklogClient::from_config(), and
call status_with(...) when tokens were loaded successfully. Ensure you reference
get_oauth_tokens, AuthDisplay::OAuth, BacklogClient::from_config, and
status_with when making the changes.

In `@src/oauth.rs`:
- Around line 316-332: The percent_decode function currently ignores '+' and
leaves it as literal plus; update percent_decode to treat '+' as a space by
handling the '+' case in the main loop (e.g., push ASCII 0x20 or extend bytes
with b' '), while keeping existing percent-decoding logic for '%' and UTF-8
encoding for other chars; modify any tests expecting "User+denied" if they
should expect "User denied". Ensure the change is made inside the percent_decode
function so all callers (OAuth error handling) receive decoded spaces.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 9889d56f-9567-4859-985b-de13e24582c6

📥 Commits

Reviewing files that changed from the base of the PR and between d32b14e and 914b725.

📒 Files selected for processing (6)
  • docs/user-guide.ja.md
  • docs/user-guide.md
  • src/api/mod.rs
  • src/cmd/auth.rs
  • src/oauth.rs
  • src/secret.rs

@23prime 23prime enabled auto-merge March 9, 2026 14:18
@23prime 23prime merged commit 524d1e7 into main Mar 9, 2026
11 of 12 checks passed
@23prime 23prime deleted the feature/oauth-login branch March 9, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants