diff --git a/.gitignore b/.gitignore
index 5bd05d6..cc77a9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,7 @@
target
-.idea
\ No newline at end of file
+.idea
+.kilo/
+!.kilo/
+.kilo/*
+!.kilo/skills/
+!.kilo/skills/**
diff --git a/.kilo/skills/create-pr/SKILL.md b/.kilo/skills/create-pr/SKILL.md
new file mode 100644
index 0000000..5cc8d3b
--- /dev/null
+++ b/.kilo/skills/create-pr/SKILL.md
@@ -0,0 +1,68 @@
+---
+name: create-pr
+description: Create GitHub pull requests with the gh CLI using the reductstore/.github PR template, then update CHANGELOG.md with the created PR ID and commit the changelog (do not push). Use when a user asks to open a PR and record its ID in the changelog.
+---
+
+# Create Pr
+
+## Overview
+
+Create a PR using the standardized template from reductstore/.github, then record the PR number in CHANGELOG.md and commit the changelog without pushing.
+
+## Workflow
+
+### 1) Preconditions
+- Ensure `gh auth status` is logged in and the current branch is the intended PR branch.
+- Ensure the working tree is clean (or only the intended changes are present).
+- Use context from the current conversation to infer intent, scope, and any constraints.
+
+### 2) Understand changes and rationale
+- Compare the current branch against `main` to understand what changed and why (use this to derive the PR title, description, and rationale):
+ - `git fetch origin main`
+ - `git log --oneline origin/main..HEAD`
+ - `git diff --stat origin/main...HEAD`
+ - `git diff origin/main...HEAD`
+- If the branch name starts with an issue number (e.g., `123-...`), use that issue in the rationale and fill the `Closes #` line in the PR template.
+- If the branch name does not include an issue number, infer the likely issue from changes and conversation context, or leave `Closes #` empty if unsure.
+
+### 3) Fetch the PR template
+Use the helper script to download the latest PR template from reductstore/.github:
+
+```bash
+./.kilo/skills/create-pr/scripts/fetch_pr_template.sh /tmp/pr_template.md
+```
+
+Fill in the template file with the relevant summary, testing, and rationale derived from the diff and conversation context.
+
+### 4) Create the PR with gh
+Use the filled template as the PR body:
+
+```bash
+gh pr create --title "
" --body-file /tmp/pr_template.md
+```
+
+Capture the PR number after creation:
+
+```bash
+gh pr view --json number -q .number
+```
+
+### 5) Update CHANGELOG.md
+- Find the appropriate section (usually the most recent/unreleased section).
+- Add a new entry following the existing style in the file.
+- Include the PR ID as `#` exactly as prior entries do.
+
+### 6) Commit (no push)
+Stage and commit the changelog update only:
+
+```bash
+git add CHANGELOG.md
+git commit -m "Update changelog for PR #"
+```
+
+Do not push.
+
+## Resources
+
+### scripts/
+- `fetch_pr_template.sh`: Download the latest PR template from reductstore/.github.
diff --git a/.kilo/skills/create-pr/scripts/fetch_pr_template.sh b/.kilo/skills/create-pr/scripts/fetch_pr_template.sh
new file mode 100755
index 0000000..cd9730b
--- /dev/null
+++ b/.kilo/skills/create-pr/scripts/fetch_pr_template.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "Usage: $0 " >&2
+ exit 1
+fi
+
+output_file="$1"
+
+curl -sS -L \
+ "https://raw.githubusercontent.com/reductstore/.github/main/.github/pull_request_template.md" \
+ -o "$output_file"
+
+if [[ ! -s "$output_file" ]]; then
+ echo "Template download failed or empty: $output_file" >&2
+ exit 1
+fi
+
+echo "Template saved to $output_file"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 620c6a6..821fce3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- Add OAuth2 login command for aliases and direct gateway URLs, [PR-271](https://github.com/reductstore/reduct-cli/pull/271) by @atimin
- Add destination prefixes to replica create, update, and show commands, [PR-247](https://github.com/reductstore/reduct-cli/pull/247) by @ychampion
- Add compression option to replica create, update, and show commands, [PR-261](https://github.com/reductstore/reduct-cli/pull/261) by @vbmade2000
- Improve time parsing for --start and --stop options, [PR-226](https://github.com/reductstore/reduct-cli/pull/226) by @yaslam-dev
diff --git a/Cargo.lock b/Cargo.lock
index 5b4524c..9176b0e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -717,6 +717,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
[[package]]
name = "hyper"
version = "1.10.1"
@@ -730,6 +736,7 @@ dependencies = [
"http",
"http-body",
"httparse",
+ "httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@@ -1458,6 +1465,9 @@ dependencies = [
"dialoguer",
"dirs",
"futures-util",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
"indicatif",
"log",
"mime_guess",
diff --git a/Cargo.toml b/Cargo.toml
index c5d16a1..981ccb4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,13 +21,16 @@ toml = "1.0.6"
serde = { version = "1.0.219", features = ["derive"] }
anyhow = "1.0.98"
url = { version = "2.5.4", features = ["serde"] }
-tokio = { version = "1.52.3", features = ["rt-multi-thread"] }
+tokio = { version = "1.52.3", features = ["io-util", "net", "rt-multi-thread", "time"] }
time-humanize = "0.1.3"
bytesize = "2.3.1"
colored = "3.0.0"
chrono = "0.4.41"
dialoguer = "0.12.0"
futures-util = { version = "0.3.30", features = [] }
+http-body-util = "0.1.3"
+hyper = { version = "1.10.1", features = ["http1", "server"] }
+hyper-util = { version = "0.1.20", features = ["tokio"] }
indicatif = "0.18.3"
async-trait = "0.1.89"
bytes = "1.11.0"
diff --git a/src/cmd.rs b/src/cmd.rs
index 2f40532..fd1584a 100644
--- a/src/cmd.rs
+++ b/src/cmd.rs
@@ -7,6 +7,7 @@ pub(crate) mod attachment;
pub(crate) mod bucket;
pub(crate) mod cp;
pub(crate) mod lifecycle;
+pub(crate) mod login;
pub(crate) mod replica;
pub(crate) mod rm;
pub(crate) mod server;
diff --git a/src/cmd/login.rs b/src/cmd/login.rs
new file mode 100644
index 0000000..be2adc8
--- /dev/null
+++ b/src/cmd/login.rs
@@ -0,0 +1,300 @@
+// Copyright 2026 ReductStore
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+use std::{net::SocketAddr, sync::Arc, time::Duration};
+
+use anyhow::{anyhow, bail, Context};
+use clap::{Arg, ArgMatches, Command};
+use http_body_util::Full;
+use hyper::{
+ body::{Bytes, Incoming},
+ header::{CONNECTION, CONTENT_TYPE},
+ server::conn::http1,
+ service::service_fn,
+ Method, Request, Response, StatusCode,
+};
+use hyper_util::rt::TokioIo;
+use tokio::{
+ net::TcpListener,
+ sync::{oneshot, Mutex},
+ time::timeout,
+};
+use url::Url;
+
+use crate::{
+ cmd::ALIAS_OR_URL_HELP,
+ config::{find_alias, ConfigFile},
+ context::CliContext,
+ io::std::output,
+};
+
+const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
+
+pub(crate) fn login_cmd() -> Command {
+ Command::new("login")
+ .about("Authenticate an alias or URL through OAuth2")
+ .arg(
+ Arg::new("ALIAS_OR_URL")
+ .help(ALIAS_OR_URL_HELP)
+ .required(true),
+ )
+}
+
+pub(crate) async fn login_handler(ctx: &CliContext, args: &ArgMatches) -> anyhow::Result<()> {
+ let alias_or_url = args
+ .get_one::("ALIAS_OR_URL")
+ .expect("required argument");
+ let target = login_target(ctx, alias_or_url)?;
+ let listener = TcpListener::bind("127.0.0.1:0")
+ .await
+ .context("Failed to start local OAuth2 callback server")?;
+ let login_url = login_url(&target.url, listener.local_addr()?)?;
+
+ output!(ctx, "Open this URL to authenticate:\n {login_url}");
+ let callback = timeout(LOGIN_TIMEOUT, receive_callback(&listener))
+ .await
+ .map_err(|_| anyhow!("Timed out waiting for OAuth2 authentication callback"))??;
+ if let Some(alias_name) = target.alias_name {
+ save_token(ctx, &alias_name, &callback.access_token)?;
+ output!(
+ ctx,
+ "Logged in to '{}'. Token expires in {}s.",
+ alias_name,
+ callback.expires_in
+ );
+ } else {
+ let mut authenticated_url = target.url;
+ authenticated_url
+ .set_username(&callback.access_token)
+ .expect("gateway URL supports a username");
+ output!(
+ ctx,
+ "Logged in to '{}'. Token expires in {}s.\nUse this URL for future commands:\n {}",
+ target.name,
+ callback.expires_in,
+ authenticated_url
+ );
+ }
+ Ok(())
+}
+
+struct LoginTarget {
+ name: String,
+ url: Url,
+ alias_name: Option,
+}
+
+struct Callback {
+ access_token: String,
+ expires_in: u64,
+}
+
+fn login_target(ctx: &CliContext, alias_or_url: &str) -> anyhow::Result {
+ if let Ok(alias) = find_alias(ctx, alias_or_url) {
+ return Ok(LoginTarget {
+ name: alias_or_url.to_owned(),
+ url: alias.url,
+ alias_name: Some(alias_or_url.to_owned()),
+ });
+ }
+
+ let mut url = Url::parse(alias_or_url)
+ .map_err(|_| anyhow!("'{}' isn't an alias or a valid HTTP URL", alias_or_url))?;
+ if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
+ bail!("'{}' isn't an alias or a valid HTTP URL", alias_or_url);
+ }
+ url.set_username("")
+ .map_err(|_| anyhow!("'{}' isn't an alias or a valid HTTP URL", alias_or_url))?;
+ url.set_password(None)
+ .map_err(|_| anyhow!("'{}' isn't an alias or a valid HTTP URL", alias_or_url))?;
+ url.set_query(None);
+ url.set_fragment(None);
+ Ok(LoginTarget {
+ name: url.to_string(),
+ url,
+ alias_name: None,
+ })
+}
+
+fn login_url(gateway_url: &Url, callback_addr: SocketAddr) -> anyhow::Result {
+ let mut url = gateway_url.join("/api/v1/auth/login")?;
+ let callback_url = format!("http://{callback_addr}/callback");
+ url.query_pairs_mut()
+ .append_pair("cli_redirect", &callback_url);
+ Ok(url)
+}
+
+async fn receive_callback(listener: &TcpListener) -> anyhow::Result {
+ let (stream, _) = listener.accept().await?;
+ let (sender, receiver) = oneshot::channel();
+ let sender = Arc::new(Mutex::new(Some(sender)));
+
+ http1::Builder::new()
+ .serve_connection(
+ TokioIo::new(stream),
+ service_fn(move |request| handle_callback(request, Arc::clone(&sender))),
+ )
+ .await
+ .context("Failed to receive OAuth2 callback")?;
+
+ receiver
+ .await
+ .context("OAuth2 callback closed without a response")?
+}
+
+async fn handle_callback(
+ request: Request,
+ sender: Arc>>>>,
+) -> Result>, hyper::Error> {
+ let callback = if request.method() == Method::GET {
+ let request_target = request
+ .uri()
+ .path_and_query()
+ .map_or("/", |path_and_query| path_and_query.as_str());
+ parse_callback(request_target)
+ } else {
+ Err(anyhow!("OAuth2 callback must use GET"))
+ };
+ let response = callback_response(&callback);
+
+ if let Some(sender) = sender.lock().await.take() {
+ let _ = sender.send(callback);
+ }
+
+ Ok(response)
+}
+
+fn callback_response(callback: &anyhow::Result) -> Response> {
+ let mut response = Response::builder().header(CONNECTION, "close");
+ if callback.is_ok() {
+ response = response
+ .status(StatusCode::OK)
+ .header(CONTENT_TYPE, "text/html; charset=utf-8");
+ response.body(Full::new(Bytes::from_static(
+ b"Authentication successful. You can close this tab.",
+ )))
+ } else {
+ response
+ .status(StatusCode::BAD_REQUEST)
+ .body(Full::new(Bytes::new()))
+ }
+ .expect("valid OAuth2 callback response")
+}
+
+fn parse_callback(request_target: &str) -> anyhow::Result {
+ let url = Url::parse(&format!("http://localhost{request_target}"))?;
+ if url.path() != "/callback" {
+ bail!("OAuth2 callback has an invalid path");
+ }
+ let query = url
+ .query_pairs()
+ .collect::>();
+ let access_token = query
+ .get("access_token")
+ .filter(|token| !token.is_empty())
+ .context("OAuth2 callback is missing access token")?
+ .to_string();
+ let expires_in = query
+ .get("expires_in")
+ .context("OAuth2 callback is missing token expiration")?
+ .parse()
+ .context("OAuth2 callback has invalid token expiration")?;
+ Ok(Callback {
+ access_token,
+ expires_in,
+ })
+}
+
+fn save_token(ctx: &CliContext, alias_name: &str, access_token: &str) -> anyhow::Result<()> {
+ let mut config_file = ConfigFile::load(ctx.config_path())?;
+ let alias = config_file
+ .mut_config()
+ .aliases
+ .get_mut(alias_name)
+ .ok_or_else(|| anyhow!("Alias '{}' does not exist", alias_name))?;
+ alias.token = access_token.to_owned();
+ config_file.save()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{config::ConfigFile, context::tests::context};
+ use rstest::rstest;
+ use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ net::TcpStream,
+ };
+
+ #[test]
+ fn parses_callback_token_and_expiration() {
+ let callback =
+ parse_callback("/callback?access_token=token%20value&expires_in=3600").unwrap();
+ assert_eq!(callback.access_token, "token value");
+ assert_eq!(callback.expires_in, 3600);
+ }
+
+ #[test]
+ fn rejects_invalid_callback() {
+ assert!(parse_callback("/other?access_token=token&expires_in=3600").is_err());
+ assert!(parse_callback("/callback?expires_in=3600").is_err());
+ assert!(parse_callback("/callback?access_token=token&expires_in=invalid").is_err());
+ }
+
+ #[rstest]
+ fn saves_received_token(context: CliContext) {
+ save_token(&context, "default", "oauth-token").unwrap();
+ let config = ConfigFile::load(context.config_path()).unwrap();
+ assert_eq!(config.config().aliases["default"].token, "oauth-token");
+ }
+
+ #[test]
+ fn constructs_loopback_login_url() {
+ let url = login_url(
+ &Url::parse("https://gateway.example.com").unwrap(),
+ "127.0.0.1:1234".parse().unwrap(),
+ )
+ .unwrap();
+ assert_eq!(url.as_str(), "https://gateway.example.com/api/v1/auth/login?cli_redirect=http%3A%2F%2F127.0.0.1%3A1234%2Fcallback");
+ }
+
+ #[rstest]
+ fn resolves_direct_gateway_url(context: CliContext) {
+ let target =
+ login_target(&context, "http://old-token@localhost:8384?ignored=value").unwrap();
+ assert_eq!(target.name, "http://localhost:8384/");
+ assert_eq!(target.url.as_str(), "http://localhost:8384/");
+ assert_eq!(target.alias_name, None);
+ }
+
+ #[rstest]
+ fn rejects_invalid_login_target(context: CliContext) {
+ assert!(login_target(&context, "invalid-target").is_err());
+ assert!(login_target(&context, "ftp://localhost:8384").is_err());
+ }
+
+ #[tokio::test]
+ async fn receives_http_callback() {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let address = listener.local_addr().unwrap();
+ let callback = tokio::spawn(async move { receive_callback(&listener).await });
+ let mut stream = TcpStream::connect(address).await.unwrap();
+ stream
+ .write_all(
+ b"GET /callback?access_token=oauth-token&expires_in=3600 HTTP/1.1\r\nHost: localhost\r\n\r\n",
+ )
+ .await
+ .unwrap();
+ let mut response = Vec::new();
+ stream.read_to_end(&mut response).await.unwrap();
+
+ let callback = callback.await.unwrap().unwrap();
+ assert_eq!(callback.access_token, "oauth-token");
+ assert_eq!(callback.expires_in, 3600);
+ assert!(std::str::from_utf8(&response)
+ .unwrap()
+ .contains("Authentication successful"));
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 234cf1a..853bd19 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,6 +18,7 @@ use std::time::Duration;
use crate::cmd::bucket::{bucket_cmd, bucket_handler};
use crate::cmd::cp::{cp_cmd, cp_handler};
use crate::cmd::lifecycle::{lifecycle_cmd, lifecycle_handler};
+use crate::cmd::login::{login_cmd, login_handler};
use crate::cmd::replica::{replication_cmd, replication_handler};
use crate::cmd::rm::{rm_cmd, rm_handler};
use crate::cmd::server::{server_cmd, server_handler};
@@ -75,6 +76,7 @@ fn cli() -> Command {
.subcommand(token_cmd())
.subcommand(replication_cmd())
.subcommand(lifecycle_cmd())
+ .subcommand(login_cmd())
.subcommand(cp_cmd())
.subcommand(rm_cmd())
}
@@ -102,6 +104,7 @@ async fn main() -> anyhow::Result<()> {
Some(("token", args)) => token_handler(&ctx, args.subcommand()).await,
Some(("replica", args)) => replication_handler(&ctx, args.subcommand()).await,
Some(("lifecycle", args)) => lifecycle_handler(&ctx, args.subcommand()).await,
+ Some(("login", args)) => login_handler(&ctx, args).await,
Some(("cp", args)) => cp_handler(&ctx, args).await,
Some(("rm", args)) => rm_handler(&ctx, args).await,