Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/frontend/human/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ use crate::frontend::style;
use crate::frontend::RenderOptions;
use crate::frontend::RenderedOutput;
use crate::protocol::output::{
AuthActionData, AuthOutput, AuthStatusData, AuthView, LogoutData, Presence, TokenState,
AuthActionData, AuthActionStatus, AuthOutput, AuthStatusData, AuthView, LogoutData, Presence,
TokenState,
};

/// Render auth command output as human-readable text
Expand Down Expand Up @@ -37,10 +38,18 @@ fn render_auth_view_text(view: &AuthView) -> (String, Option<String>) {
),
Some(render_attention_details(data)),
),
AuthView::LoginSuccess(data) => (
style::success("Authenticated", style::is_stdout_enabled()),
Some(render_login_success_details(data)),
),
AuthView::LoginSuccess(data) => {
let headline_text = match data.status {
AuthActionStatus::LoggedIn | AuthActionStatus::AlreadyAuthenticated => {
"Authenticated"
}
AuthActionStatus::Refreshed => "Session refreshed",
};
(
style::success(headline_text, style::is_stdout_enabled()),
Some(render_login_success_details(data)),
)
}
AuthView::LogoutSuccess(data) => (
style::success("Credentials cleared", style::is_stdout_enabled()),
Some(render_logout_details(data)),
Expand Down
5 changes: 3 additions & 2 deletions src/frontend/json/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ fn render_auth_view_json(view: &AuthView) -> Result<String, CliError> {
}
AuthView::LoginSuccess(data) => {
let status = match data.status {
AuthActionStatus::LoggedIn => "logged in",
AuthActionStatus::AlreadyAuthenticated => "already authenticated",
AuthActionStatus::LoggedIn => "logged_in",
AuthActionStatus::AlreadyAuthenticated => "already_authenticated",
AuthActionStatus::Refreshed => "refreshed",
};
serde_json::json!({ "status": status })
}
Expand Down
5 changes: 4 additions & 1 deletion src/invocation/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,10 @@ fn build_login_subcommand() -> Command {
\x20 Base URL: --base-url → AGS_BASE_URL → config → prompt\n\
\x20 Client ID: --client-id → AGS_CLIENT_ID → config → prompt\n\
\x20 Client Secret: --client-secret → AGS_CLIENT_SECRET → keychain → prompt\n\n\
\x20 With --format json, outputs {\"status\": \"authenticated\"} on success.",
\x20 With --format json, outputs one of:\n\
\x20 {\"status\": \"logged_in\"} — fresh successful grant\n\
\x20 {\"status\": \"already_authenticated\"} — probe found a valid session\n\
\x20 {\"status\": \"refreshed\"} — probe refreshed an expired token",
)
.arg(
Arg::new("grant")
Expand Down
75 changes: 56 additions & 19 deletions src/invocation/commands/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,19 +86,6 @@ async fn handle_auth_login(
handle_login_with_client_credentials(matches, flags, profile, runtime, frontend).await
}
GrantType::AuthorizationCode => {
// Authorization code login opens a browser and prints instructions
// to stderr. That is incompatible with any non-interactive mode:
// --no-input, or --format json which is inherently machine-driven.
if flags.is_no_input
|| flags.format == Some(crate::protocol::request::OutputFormat::Json)
{
return Err(CliError::Usage {
message: "Authorization code flow requires browser interaction and cannot run non-interactively.\n\
Use '--grant client-credentials' for headless authentication."
.to_string(),
metadata: None,
});
}
handle_login_with_browser(matches, flags, profile, runtime, frontend).await
}
}
Expand All @@ -111,7 +98,7 @@ async fn handle_auth_login(
/// the token exchange + persistence to `operations::login_with_authorization_code`.
async fn handle_login_with_browser(
matches: &ArgMatches,
_flags: &GlobalFlags,
flags: &GlobalFlags,
profile: &str,
runtime: &crate::runtime::Runtime,
frontend: &mut dyn crate::frontend::Frontend,
Expand All @@ -123,24 +110,57 @@ async fn handle_login_with_browser(
.and_then(|p| p.parse::<u16>().ok())
.unwrap_or(8080);

// Non-interactive modes can't drive the browser flow, but they CAN still
// benefit from the probe (a valid stored session means no browser is
// needed). So we resolve identity → probe → reject only if both fail.
let is_prompt_blocked =
flags.is_no_input || flags.format == Some(crate::protocol::request::OutputFormat::Json);

let base_url = resolve_login_value(
flag_base_url,
crate::runtime::auth::credentials::resolve_base_url_value(profile),
false,
is_prompt_blocked,
"Enter Base URL (e.g. https://demo.accelbyte.io): ",
"Base URL is required.",
"When reading from stdin, --base-url or AGS_BASE_URL must be provided.",
"Provide --base-url or set AGS_BASE_URL when running non-interactively.",
)?;

let client_id = resolve_login_value(
flag_client_id,
crate::runtime::auth::credentials::resolve_client_id_value(profile),
false,
is_prompt_blocked,
"Enter Client ID: ",
"Client ID is required.",
"When reading from stdin, --client-id or AGS_CLIENT_ID must be provided.",
"Provide --client-id or set AGS_CLIENT_ID when running non-interactively.",
)?;

// Probe BEFORE binding the callback port or printing any browser URL.
// If the user already has a usable (or refreshable) session, we report
// that and stop here — no browser tab, no callback wait.
if let Some(view) = runtime
.auth_probe_existing_session(
profile,
base_url.clone(),
client_id.clone(),
"authorization code",
frontend.progress_sink(),
)
.await?
{
return Ok(CommandOutput::Auth(AuthOutput { view }));
}

// Probe didn't short-circuit and the browser flow needs a TTY. Reject
// cleanly instead of opening a browser the caller can't drive.
if is_prompt_blocked {
return Err(CliError::Usage {
message: "Authorization code flow requires browser interaction and cannot run non-interactively.\n\
Use '--grant client-credentials' for headless authentication."
.to_string(),
metadata: None,
});
}

// Generate PKCE pair and state
let (code_verifier, code_challenge) = oauth::generate_pkce_pair();
let state = oauth::generate_state();
Expand Down Expand Up @@ -203,7 +223,9 @@ async fn handle_login_with_client_credentials(
);
}

let is_prompt_blocked = flags.is_no_input || flag_client_secret_stdin;
let is_prompt_blocked = flags.is_no_input
|| flag_client_secret_stdin
|| flags.format == Some(crate::protocol::request::OutputFormat::Json);

let base_url = resolve_login_value(
flag_base_url,
Expand All @@ -223,6 +245,21 @@ async fn handle_login_with_client_credentials(
"Provide --client-id or set AGS_CLIENT_ID when using --no-input.",
)?;

// Probe BEFORE prompting for the client secret. If the existing session
// is good (or can be refreshed), we don't need the secret at all.
if let Some(view) = runtime
.auth_probe_existing_session(
profile,
base_url.clone(),
client_id.clone(),
"client credentials",
frontend.progress_sink(),
)
.await?
{
return Ok(CommandOutput::Auth(AuthOutput { view }));
}

let client_secret = resolve_client_secret_for_login(
profile,
flag_client_secret,
Expand Down
2 changes: 2 additions & 0 deletions src/protocol/output_views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ pub enum AuthActionStatus {
LoggedIn,
/// Login was a no-op because valid credentials already exist.
AlreadyAuthenticated,
/// Login refreshed a stale session in place; no fresh OAuth ran.
Refreshed,
}

/// Data from a successful login action for the confirmation display.
Expand Down
Loading
Loading