diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0e7afe0f..3b232df0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -579,9 +579,9 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-engine" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7775240f99e1f7f533a73f25753362aea7dd9f183e5227bcf13caca0195f22f" +checksum = "5449fc189b2fbd0293f30704aff260123751028b5809918cd6fa814611cf2a16" dependencies = [ "async-trait", "base64", @@ -592,6 +592,7 @@ dependencies = [ "cli-engine-macros", "inquire", "jmespath", + "jsonwebtoken", "keyring", "open", "rand 0.9.5", @@ -2155,6 +2156,21 @@ dependencies = [ "uuid-simd", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "base64", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature", + "zeroize", +] + [[package]] name = "keyring" version = "3.6.3" @@ -3945,6 +3961,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f96b08ef..57912083 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -28,7 +28,7 @@ async-trait = "0.1" bytes = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } clap = { version = "4.5", features = ["std", "string", "derive"] } -cli-engine = { version = "0.10.0", features = ["pkce-auth"] } +cli-engine = { version = "0.10.1", features = ["pkce-auth"] } dirs = "6" domains-client = { path = "domains-client" } email-client = { path = "email-client" } diff --git a/rust/src/api/operation.rs b/rust/src/api/operation.rs index 5f619591..17da1a6f 100644 --- a/rust/src/api/operation.rs +++ b/rust/src/api/operation.rs @@ -512,7 +512,7 @@ mod tests { /// Same ambiguity, but through human output — covers that rendering /// path directly, since every other test here only exercises /// `--output json`. Human error rendering is `Error: {message}` / - /// `Fix: {fix}` (`cli_engine::output::human::render_human_with_view`). + /// `Fix: {fix}`. #[tokio::test] async fn operation_get_multiple_fuzzy_matches_human_output_shows_message_and_candidates() { let output = operation_cli() diff --git a/rust/src/api/parameter.rs b/rust/src/api/parameter.rs index 8876583e..48b1ec8e 100644 --- a/rust/src/api/parameter.rs +++ b/rust/src/api/parameter.rs @@ -42,10 +42,10 @@ pub(super) fn list_command() -> RuntimeCommandSpec { .with_system("api") .with_tier(Tier::Read) .no_auth(true) - .with_pagination(PaginationConfig { - default_limit: OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, - max_limit: OPERATION_CHILD_LIST_MAX_LIMIT, - }), + .with_pagination(PaginationConfig::new( + OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, + OPERATION_CHILD_LIST_MAX_LIMIT, + )), |_cred, args: ParameterListArgs| async move { let operation = args.operation.as_str(); let catalog = catalog(); diff --git a/rust/src/api/response.rs b/rust/src/api/response.rs index 88d0b76e..62d18782 100644 --- a/rust/src/api/response.rs +++ b/rust/src/api/response.rs @@ -37,10 +37,10 @@ pub(super) fn list_command() -> RuntimeCommandSpec { .with_system("api") .with_tier(Tier::Read) .no_auth(true) - .with_pagination(PaginationConfig { - default_limit: OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, - max_limit: OPERATION_CHILD_LIST_MAX_LIMIT, - }), + .with_pagination(PaginationConfig::new( + OPERATION_CHILD_LIST_DEFAULT_LIMIT as i64, + OPERATION_CHILD_LIST_MAX_LIMIT, + )), |_cred, args: ResponseListArgs| async move { let operation = args.operation.as_str(); let catalog = catalog(); diff --git a/rust/src/api/search.rs b/rust/src/api/search.rs index 8ccfacb1..4d9f97e3 100644 --- a/rust/src/api/search.rs +++ b/rust/src/api/search.rs @@ -44,10 +44,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .no_auth(true) .with_default_fields("operationId,domain,method,path,summary") .with_output_schema::() - .with_pagination(PaginationConfig { - max_limit: 100, - ..Default::default() - }), + .with_pagination(PaginationConfig::new(0, 100)), |_cred, args: SearchArgs| async move { let hits = search_endpoints(catalog(), &args.query); if hits.is_empty() { @@ -130,10 +127,7 @@ mod tests { fn command_opts_into_pagination_with_no_default_and_a_max_limit() { assert_eq!( command().spec.pagination, - Some(PaginationConfig { - max_limit: 100, - ..Default::default() - }) + Some(PaginationConfig::new(0, 100)) ); } } diff --git a/rust/src/auth.rs b/rust/src/auth.rs index 2033fbd9..e3469432 100644 --- a/rust/src/auth.rs +++ b/rust/src/auth.rs @@ -64,7 +64,7 @@ fn build_provider(env: &GddyEnvConfig) -> PkceAuthProvider { environments::DEFAULT_OAUTH_SCOPES, ) .with_app_id(environments::APP_ID) - .with_redirect_uri(environments::REDIRECT_URI) + .with_ephemeral_redirect_port() .with_environments(Arc::clone(environments::instance())) } @@ -124,14 +124,13 @@ fn log_resolved_oauth(env: &GddyEnvConfig) { auth_url_from_env_var = auth_url_ovr.is_some(), token_url = %token_url_ovr.as_deref().unwrap_or(&env.token_url), token_url_from_env_var = token_url_ovr.is_some(), - redirect_uri = environments::REDIRECT_URI, + redirect_uri = "http://127.0.0.1:/callback", "resolved OAuth client for login/token exchange" ); } /// Rejects any scope in `requested` that isn't registered in [`scopes`] — -/// [`scopes::ALL`] plus the standalone [`scopes::OFFLINE_ACCESS`] directive -/// scope. +/// [`scopes::ALL`] plus the standalone [`scopes::DIRECTIVES`] scopes. /// /// A scope missing from that list is, by construction, either misspelled or /// was never registered on the CLI's OAuth client (see the "Adding a scope" @@ -143,7 +142,7 @@ fn validate_requested_scopes(requested: &[String]) -> Result<()> { let unknown: Vec<&str> = requested .iter() .map(String::as_str) - .filter(|scope| *scope != scopes::OFFLINE_ACCESS && !scopes::ALL.contains(scope)) + .filter(|scope| !scopes::DIRECTIVES.contains(scope) && !scopes::ALL.contains(scope)) .filter(|scope| seen.insert(*scope)) .collect(); if unknown.is_empty() { @@ -263,8 +262,13 @@ mod tests { } #[test] - fn validate_requested_scopes_accepts_offline_access() { - assert!(validate_requested_scopes(&[scopes::OFFLINE_ACCESS.to_owned()]).is_ok()); + fn validate_requested_scopes_accepts_directive_scopes() { + for scope in scopes::DIRECTIVES { + assert!( + validate_requested_scopes(&[(*scope).to_owned()]).is_ok(), + "directive scope {scope:?} was rejected" + ); + } } #[test] diff --git a/rust/src/dns/list.rs b/rust/src/dns/list.rs index 0659b7f3..e8875be6 100644 --- a/rust/src/dns/list.rs +++ b/rust/src/dns/list.rs @@ -68,10 +68,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_json_schema::() .with_view(view_columns()) .with_scopes(&[DOMAINS_READ]) - .with_pagination(PaginationConfig { - max_limit: 500, - ..Default::default() - }), + .with_pagination(PaginationConfig::new(0, 500)), |ctx, args: ListArgs| async move { let domain = args.domain; let type_opt = args.record_type; @@ -126,10 +123,7 @@ mod tests { fn opts_into_pagination_with_no_default_and_a_max_limit() { assert_eq!( command().spec.pagination, - Some(PaginationConfig { - max_limit: 500, - ..Default::default() - }) + Some(PaginationConfig::new(0, 500)) ); } diff --git a/rust/src/domain/list.rs b/rust/src/domain/list.rs index bec3bb62..ae56c3f9 100644 --- a/rust/src/domain/list.rs +++ b/rust/src/domain/list.rs @@ -248,10 +248,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_default_fields("domain,status,expiresAt,autoRenew") .with_json_schema::() .with_scopes(&[DOMAINS_READ]) - .with_pagination(PaginationConfig { - max_limit: 500, - ..Default::default() - }), + .with_pagination(PaginationConfig::new(0, 500)), |ctx, args: ListArgs| async move { let debug = !ctx.middleware.debug.is_empty(); let statuses = parse_statuses(&args.status)?; @@ -303,10 +300,7 @@ mod tests { fn opts_into_pagination_with_no_default_and_a_max_limit() { assert_eq!( command().spec.pagination, - Some(PaginationConfig { - max_limit: 500, - ..Default::default() - }) + Some(PaginationConfig::new(0, 500)) ); } diff --git a/rust/src/email/list.rs b/rust/src/email/list.rs index a431a5b5..df695a13 100644 --- a/rust/src/email/list.rs +++ b/rust/src/email/list.rs @@ -35,10 +35,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_system("email") .with_tier(Tier::Read) .with_scopes(&[EMAIL_READ]) - .with_pagination(PaginationConfig { - default_limit: 25, - max_limit: 500, - }), + .with_pagination(PaginationConfig::new(25, 500)), |ctx, args: ListArgs| async move { let client = make_client(&ctx, &[EMAIL_READ]).await?; let mailboxes = fetch_mailboxes( @@ -124,10 +121,7 @@ mod tests { fn opts_into_pagination_with_a_default_and_a_max_limit() { assert_eq!( command().spec.pagination, - Some(PaginationConfig { - default_limit: 25, - max_limit: 500, - }) + Some(PaginationConfig::new(25, 500)) ); } diff --git a/rust/src/environments/mod.rs b/rust/src/environments/mod.rs index 870b069f..7e8d46c1 100644 --- a/rust/src/environments/mod.rs +++ b/rust/src/environments/mod.rs @@ -77,8 +77,9 @@ pub const DEFAULT_OAUTH_SCOPES: &[&str] = &[ crate::scopes::APP_REGISTRY_READ, crate::scopes::DOMAINS_READ, crate::scopes::OFFLINE_ACCESS, + crate::scopes::OPENID, + crate::scopes::PROFILE, ]; -pub const REDIRECT_URI: &str = "http://localhost:7443/callback"; pub const APP_ID: &str = "gddy"; pub fn env_prefix(name: &str) -> String { diff --git a/rust/src/platform/actions/mod.rs b/rust/src/platform/actions/mod.rs index 6e4e3fb0..9d3dfba2 100644 --- a/rust/src/platform/actions/mod.rs +++ b/rust/src/platform/actions/mod.rs @@ -102,10 +102,7 @@ pub fn group() -> RuntimeGroupSpec { .no_auth(true) .with_default_fields("name,description") .with_output_schema::() - .with_pagination(PaginationConfig { - default_limit: 50, - max_limit: 200, - }), + .with_pagination(PaginationConfig::new(50, 200)), |_cred, _args| async move { let actions: Vec<_> = ACTIONS .iter() diff --git a/rust/src/platform/app/commands/list.rs b/rust/src/platform/app/commands/list.rs index 4f2a7b2b..3a54798a 100644 --- a/rust/src/platform/app/commands/list.rs +++ b/rust/src/platform/app/commands/list.rs @@ -20,10 +20,7 @@ pub(super) fn command() -> RuntimeCommandSpec { .with_tier(Tier::Read) .with_default_fields("name,label,status") .with_output_schema::() - .with_pagination(PaginationConfig { - max_limit: 200, - ..Default::default() - }), + .with_pagination(PaginationConfig::new(0, 200)), |ctx| async move { let client = super::make_client(&ctx).await?; let data = client @@ -56,10 +53,7 @@ mod tests { fn command_opts_into_pagination_with_no_default_and_a_max_limit() { assert_eq!( command().spec.pagination, - Some(PaginationConfig { - max_limit: 200, - ..Default::default() - }) + Some(PaginationConfig::new(0, 200)) ); } diff --git a/rust/src/scopes.rs b/rust/src/scopes.rs index 498471d3..f16caa8f 100644 --- a/rust/src/scopes.rs +++ b/rust/src/scopes.rs @@ -19,15 +19,16 @@ //! //! `ALL` does NOT cover every scope requiring client registration, though: //! directive scopes like [`OFFLINE_ACCESS`] aren't `resource:action` grants, so -//! they're declared outside [`declare_scopes!`]/`ALL` but still need the same -//! server-side registration. When syncing the OAuth client's configuration, -//! diff against `ALL` *plus* every such standalone constant, not `ALL` alone. +//! they're declared outside [`declare_scopes!`]/`ALL` (and listed in +//! [`DIRECTIVES`]) but still need the same server-side registration. When +//! syncing the OAuth client's configuration, diff against `ALL` *plus* +//! [`DIRECTIVES`], not `ALL` alone. //! //! # Adding a scope (READ THIS) //! //! 1. Add a constant to the [`declare_scopes!`] block below (or, for a directive //! scope that isn't a `resource:action` permission, declare it standalone -//! like [`OFFLINE_ACCESS`]). Constants in `declare_scopes!` are automatically +//! like [`OFFLINE_ACCESS`], and add it to [`DIRECTIVES`]). Constants in `declare_scopes!` are automatically //! included in [`ALL`] — you cannot add one there without registering it in //! the list. //! 2. Reference the new constant from the command via `.with_scopes(&[scopes::…])`. @@ -52,10 +53,10 @@ macro_rules! declare_scopes { /// registration in sync with this list. /// /// NOT the complete set of scopes requiring OAuth client registration: - /// [`OFFLINE_ACCESS`] is a directive scope (not a `resource:action` permission) - /// and is deliberately excluded, but still must be registered on the client - /// server-side. Diff the client's configuration against `ALL` *plus* - /// [`OFFLINE_ACCESS`], not `ALL` alone. + /// the directive scopes in [`DIRECTIVES`] (e.g. [`OFFLINE_ACCESS`]) aren't + /// `resource:action` permissions and are deliberately excluded, but still must + /// be registered on the client server-side. Diff the client's configuration + /// against `ALL` *plus* [`DIRECTIVES`], not `ALL` alone. /// /// Not referenced by production code (the individual constants are what /// commands use); it exists as the authoritative registry to diff against @@ -77,6 +78,21 @@ macro_rules! declare_scopes { /// unregistered scope. pub const OFFLINE_ACCESS: &str = "offline_access"; +/// OIDC directive scope marking the request as an identity (not just +/// authorization) request — some IDPs only populate profile claims when this +/// is present alongside [`PROFILE`], even though `profile` alone is meant to +/// carry the claims. +pub const OPENID: &str = "openid"; + +/// OIDC directive scope requesting standard profile claims (`name`, +/// `given_name`, `preferred_username`, etc.) on the token. +pub const PROFILE: &str = "profile"; + +/// Every standalone directive scope — the scopes that require OAuth client +/// registration but, not being `resource:action` permissions, live outside +/// [`ALL`]. Diff the client's configuration against `ALL` *plus* this list. +pub const DIRECTIVES: &[&str] = &[OFFLINE_ACCESS, OPENID, PROFILE]; + // DON'T FORGET! If you add a scope here, you must also register it on the CLI's OAuth client. declare_scopes! { /// Read the caller's registered applications. Requested at login by default @@ -308,6 +324,16 @@ pub const SCOPE_REGISTRY: &[ScopeInfo] = &[ description: "Request a refresh token", default: true, }, + ScopeInfo { + scope: OPENID, + description: "Mark the request as an OIDC identity request", + default: true, + }, + ScopeInfo { + scope: PROFILE, + description: "Request standard OIDC profile claims on the token", + default: true, + }, ]; /// Every leaf command's space-separated invocation path (e.g. `"domain @@ -439,14 +465,13 @@ mod tests { } /// [`SCOPE_REGISTRY`] must describe every scope requiring OAuth client - /// registration — `ALL` plus the standalone directive scopes - /// ([`OFFLINE_ACCESS`]) — or `gddy auth scope` would silently omit a + /// registration — `ALL` plus the standalone [`DIRECTIVES`] — or `gddy auth scope` would silently omit a /// scope an agent needs to plan an eager login around. #[test] fn scope_registry_covers_every_declared_scope() { let registered: std::collections::HashSet<&str> = SCOPE_REGISTRY.iter().map(|info| info.scope).collect(); - for scope in ALL.iter().copied().chain([OFFLINE_ACCESS]) { + for scope in ALL.iter().chain(DIRECTIVES).copied() { assert!( registered.contains(scope), "scope {scope:?} is declared but missing from SCOPE_REGISTRY"