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
8 changes: 7 additions & 1 deletion src/auth/dcr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,13 @@ impl DcrClient {
subdomain: Option<&str>,
org_uuid: Option<&str>,
) -> String {
let scope = scopes.join(" ");
// Sort scopes so the printed authorize URL has a deterministic
// `scope=` parameter order — easier to diff and grep across runs.
// OAuth treats `scope` as an unordered set, so this is a no-op for
// the issuer.
let mut sorted_scopes: Vec<&str> = scopes.to_vec();
sorted_scopes.sort();
let scope = sorted_scopes.join(" ");
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer
.append_pair("response_type", "code")
Expand Down
43 changes: 31 additions & 12 deletions src/commands/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ where
f(&mut **store)
}

/// Parse a token's space-delimited scope claim and return scopes sorted
/// alphabetically. The OAuth issuer returns scopes in non-deterministic
/// order; sorting makes `pup auth list`/`pup auth status` output stable
/// and easier to scan or diff between invocations.
fn sorted_scopes(scope_claim: &str) -> Vec<&str> {
let mut scopes: Vec<&str> = scope_claim.split_whitespace().collect();
scopes.sort();
scopes
}

/// Format the trailing " (org: <name>)" segment used in user-facing log lines.
/// Returns an empty string when there's no org so the surrounding messages stay
/// clean for the default-org case.
Expand Down Expand Up @@ -64,10 +74,12 @@ pub async fn login(
scopes.len()
);
} else {
let mut display_scopes = scopes.clone();
display_scopes.sort();
eprintln!(
"🔑 Requesting {} scope(s): {}",
scopes.len(),
scopes.join(", ")
display_scopes.join(", ")
);
}

Expand Down Expand Up @@ -380,11 +392,7 @@ pub fn status(cfg: &Config) -> Result<()> {
.map(|dt| dt.with_timezone(&chrono::Local).to_rfc3339())
.unwrap_or_default();

let scopes: Vec<&str> = tokens
.scope
.split_whitespace()
.filter(|s| !s.is_empty())
.collect();
let scopes = sorted_scopes(&tokens.scope);

let json = serde_json::json!({
"authenticated": true,
Expand Down Expand Up @@ -504,17 +512,12 @@ pub fn list(cfg: &Config) -> Result<()> {
let expires_at = chrono::DateTime::from_timestamp(expires_at_ts, 0)
.map(|dt| dt.with_timezone(&chrono::Local).to_rfc3339())
.unwrap_or_default();
let scopes: Vec<&str> = t
.scope
.split_whitespace()
.filter(|s| !s.is_empty())
.collect();
serde_json::json!({
"expires_at": expires_at,
"has_refresh": !t.refresh_token.is_empty(),
"org": s.org,
"org_uuid": s.org_uuid,
"scopes": scopes,
"scopes": sorted_scopes(&t.scope),
"site": s.site,
"status": status,
})
Expand Down Expand Up @@ -588,6 +591,22 @@ mod tests {
}
}

// ------------------------------------------------------------------
// sorted_scopes — pure helper, no env / storage state.
// ------------------------------------------------------------------

#[test]
fn sorted_scopes_alphabetises_the_claim() {
let got = sorted_scopes("monitors_read apm_read dashboards_read");
assert_eq!(got, vec!["apm_read", "dashboards_read", "monitors_read"]);
}

#[test]
fn sorted_scopes_handles_empty_and_whitespace_input() {
assert!(sorted_scopes("").is_empty());
assert!(sorted_scopes(" ").is_empty());
}

// ------------------------------------------------------------------
// token() — the one function with an access_token bypass that never
// touches the global STORAGE singleton. Hermetic.
Expand Down
Loading