diff --git a/libs/ferriskey-cli-client/src/lib.rs b/libs/ferriskey-cli-client/src/lib.rs index 4e0231e..49d58e1 100644 --- a/libs/ferriskey-cli-client/src/lib.rs +++ b/libs/ferriskey-cli-client/src/lib.rs @@ -379,6 +379,19 @@ impl FerriskeyClient { .find(|client| client.client_id.as_deref() == Some(client_id))) } + /// Read a confidential client's secret. Unlike the client read endpoints, + /// which mask it as `"***"`, this dedicated (server-audited) endpoint + /// returns the raw value. + pub fn get_client_secret( + &self, + realm: &str, + client_uuid: &str, + ) -> Result { + let payload: ClientSecretPayload = + self.get_json(&self.endpoint(&format!("realms/{realm}/clients/{client_uuid}/client-secret")))?; + Ok(payload.into_secret()) + } + pub fn list_users(&self, realm: &str) -> Result, FerriskeyClientError> { self.get_list(&self.endpoint(&format!("realms/{realm}/users"))) } @@ -973,3 +986,57 @@ enum ListPayload { struct DataEnvelope { data: T, } + +/// Shape of the client-secret endpoint's response isn't documented; this +/// accepts the plausible variants (bare string, `{secret}`, and either +/// enveloped in `{data: ...}`, matching the `{data: ...}` shape already seen +/// on other single-entity reads in this API) rather than assuming just one. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ClientSecretPayload { + Raw(String), + Object { secret: String }, + Enveloped { data: Box }, +} + +impl ClientSecretPayload { + fn into_secret(self) -> String { + match self { + ClientSecretPayload::Raw(secret) => secret, + ClientSecretPayload::Object { secret } => secret, + ClientSecretPayload::Enveloped { data } => data.into_secret(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_secret_payload_accepts_bare_string() { + let payload: ClientSecretPayload = serde_json::from_str("\"s3cr3t\"").unwrap(); + assert_eq!(payload.into_secret(), "s3cr3t"); + } + + #[test] + fn client_secret_payload_accepts_object() { + let payload: ClientSecretPayload = + serde_json::from_str(r#"{"secret":"s3cr3t"}"#).unwrap(); + assert_eq!(payload.into_secret(), "s3cr3t"); + } + + #[test] + fn client_secret_payload_accepts_enveloped_string() { + let payload: ClientSecretPayload = + serde_json::from_str(r#"{"data":"s3cr3t"}"#).unwrap(); + assert_eq!(payload.into_secret(), "s3cr3t"); + } + + #[test] + fn client_secret_payload_accepts_enveloped_object() { + let payload: ClientSecretPayload = + serde_json::from_str(r#"{"data":{"secret":"s3cr3t"}}"#).unwrap(); + assert_eq!(payload.into_secret(), "s3cr3t"); + } +} diff --git a/libs/ferriskey-cli-commands/src/client.rs b/libs/ferriskey-cli-commands/src/client.rs index 793a778..898a1ed 100644 --- a/libs/ferriskey-cli-commands/src/client.rs +++ b/libs/ferriskey-cli-commands/src/client.rs @@ -19,6 +19,8 @@ pub enum ClientSubcommand { Create(ClientCreateArgs), /// Delete a client. Delete(ClientDeleteArgs), + /// Print a confidential client's secret to stdout. + Secret(ClientSecretArgs), } /// Arguments for listing clients. @@ -71,6 +73,17 @@ pub struct ClientCreateArgs { pub direct_access_grants_enabled: bool, } +/// Arguments for reading a client's secret. +#[derive(Debug, Args)] +pub struct ClientSecretArgs { + /// Client identifier. + pub client_id: String, + + /// Realm name. Defaults to the selected context realm. + #[arg(long)] + pub realm: Option, +} + /// Supported client types. #[derive(Clone, Debug, ValueEnum)] pub enum ClientType { diff --git a/libs/ferriskey-cli-commands/src/lib.rs b/libs/ferriskey-cli-commands/src/lib.rs index 3503703..06d3697 100644 --- a/libs/ferriskey-cli-commands/src/lib.rs +++ b/libs/ferriskey-cli-commands/src/lib.rs @@ -7,7 +7,7 @@ mod user; pub use self::client::{ ClientCommand, ClientCreateArgs, ClientDeleteArgs, ClientGetArgs, ClientListArgs, - ClientSubcommand, ClientType, + ClientSecretArgs, ClientSubcommand, ClientType, }; pub use self::context::{ ContextAddArgs, ContextCommand, ContextRemoveArgs, ContextSubcommand, ContextUseArgs, diff --git a/libs/ferriskey-cli-commands/src/user.rs b/libs/ferriskey-cli-commands/src/user.rs index f7beeab..a65b8fe 100644 --- a/libs/ferriskey-cli-commands/src/user.rs +++ b/libs/ferriskey-cli-commands/src/user.rs @@ -20,9 +20,9 @@ pub enum UserSubcommand { Create(UserCreateArgs), /// Delete a user. Delete(UserDeleteArgs), - /// Assign a realm role to a user. + /// Assign a realm or client role to a user. AssignRole(UserAssignRoleArgs), - /// Remove a realm role from a user. + /// Remove a realm or client role from a user. RemoveRole(UserRemoveRoleArgs), /// List the realm roles assigned to a user. Roles(UserRolesArgs), @@ -30,32 +30,40 @@ pub enum UserSubcommand { SetPassword(UserSetPasswordArgs), } -/// Arguments for assigning a realm role to a user. +/// Arguments for assigning a realm or client role to a user. #[derive(Debug, Args)] pub struct UserAssignRoleArgs { /// Username. pub username: String, - /// Realm role name to assign. + /// Role name to assign. pub role: String, /// Realm name. Defaults to the selected context realm. #[arg(long)] pub realm: Option, + + /// Assign a role of this client instead of a realm role. + #[arg(long)] + pub client: Option, } -/// Arguments for removing a realm role from a user. +/// Arguments for removing a realm or client role from a user. #[derive(Debug, Args)] pub struct UserRemoveRoleArgs { /// Username. pub username: String, - /// Realm role name to remove. + /// Role name to remove. pub role: String, /// Realm name. Defaults to the selected context realm. #[arg(long)] pub realm: Option, + + /// Remove a role of this client instead of a realm role. + #[arg(long)] + pub client: Option, } /// Arguments for listing a user's realm roles. diff --git a/libs/ferriskey-cli-core/src/client.rs b/libs/ferriskey-cli-core/src/client.rs index e75edc0..316b189 100644 --- a/libs/ferriskey-cli-core/src/client.rs +++ b/libs/ferriskey-cli-core/src/client.rs @@ -3,7 +3,7 @@ use ferriskey_cli_client::{ }; use ferriskey_cli_commands::{ ClientCommand, ClientCreateArgs, ClientDeleteArgs, ClientGetArgs, ClientListArgs, - ClientSubcommand, ClientType, + ClientSecretArgs, ClientSubcommand, ClientType, }; use serde::Serialize; use thiserror::Error; @@ -33,6 +33,9 @@ pub fn run( ClientSubcommand::Delete(args) => { delete_client(output_format, context_override, inline_context, args) } + ClientSubcommand::Secret(args) => { + get_client_secret(context_override, inline_context, args) + } } } @@ -155,6 +158,29 @@ fn get_client( render_client_detail(output_format, to_detail_view(result, realm)) } +/// Reads a confidential client's secret. Deliberately ignores `--output`: +/// the secret is printed bare on stdout so it can be piped or captured +/// directly (`ferris-ctl client secret x > secret.txt`), with everything +/// else — status, prompts, errors — kept on stderr. +fn get_client_secret( + context_override: Option<&str>, + inline_context: Option, + args: ClientSecretArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let realm = resolve_realm(&context, args.realm.clone())?; + let client = auth_client(&context)?; + let found = client + .get_client(&realm, &args.client_id)? + .ok_or_else(|| ClientCommandError::ClientNotFound(args.client_id.clone()))?; + let uuid = found + .id + .ok_or_else(|| ClientCommandError::ClientNotFound(args.client_id.clone()))?; + let secret = client.get_client_secret(&realm, &uuid)?; + println!("{secret}"); + Ok(()) +} + fn list_clients( output_format: &str, context_override: Option<&str>, diff --git a/libs/ferriskey-cli-core/src/import/apply.rs b/libs/ferriskey-cli-core/src/import/apply.rs index 35023a8..8afeb6d 100644 --- a/libs/ferriskey-cli-core/src/import/apply.rs +++ b/libs/ferriskey-cli-core/src/import/apply.rs @@ -14,7 +14,9 @@ use ferriskey_cli_client::{ }; use reqwest::StatusCode; -use super::{ClientBlueprint, ImportError, ImportReport, RealmBlueprint, RoleBlueprint}; +use super::{ + ClientBlueprint, ClientSecretEntry, ImportError, ImportReport, RealmBlueprint, RoleBlueprint, +}; /// Apply `blueprint` to the FerrisKey instance behind `client`. /// @@ -129,6 +131,19 @@ pub fn apply_blueprint( }; client_uuids.insert(client_bp.client_id.clone(), client_uuid.clone()); + if client_bp.client_type == "confidential" { + match client.get_client_secret(realm, &client_uuid) { + Ok(secret) => report.client_secrets.push(ClientSecretEntry { + client_id: client_bp.client_id.clone(), + secret, + }), + Err(e) => report.warnings.push(format!( + "could not read secret of client '{}': {e}", + client_bp.client_id + )), + } + } + for uri in &client_bp.redirect_uris { let request = CreateRedirectUriRequest { value: uri.clone(), diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index db22120..2eb81ce 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -200,10 +200,21 @@ pub struct ImportReport { /// Entities skipped because they already existed — distinguishes a /// converging replay from a run that did nothing. pub already_present: usize, + /// Secret of every confidential client the import touched, so the + /// import is self-sufficient — the caller doesn't need a separate + /// `client secret` call per client. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub client_secrets: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] pub warnings: Vec, } +#[derive(Debug, Clone, Serialize)] +pub struct ClientSecretEntry { + pub client_id: String, + pub secret: String, +} + #[derive(Debug, Error)] pub enum ImportError { #[error(transparent)] diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs index 7284086..6cca6ad 100644 --- a/libs/ferriskey-cli-core/src/realm.rs +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -421,6 +421,12 @@ fn render_reports(output_format: &str, reports: &[ImportReport]) -> Result<()> { println!(" users created: {}", report.users_created); println!(" role assignments: {}", report.role_assignments); println!(" already present: {}", report.already_present); + if !report.client_secrets.is_empty() { + println!(" client secrets:"); + for entry in &report.client_secrets { + println!(" {}: {}", entry.client_id, entry.secret); + } + } if !report.warnings.is_empty() { println!(" warnings:"); for warning in &report.warnings { diff --git a/libs/ferriskey-cli-core/src/user.rs b/libs/ferriskey-cli-core/src/user.rs index f020955..f2a4154 100644 --- a/libs/ferriskey-cli-core/src/user.rs +++ b/libs/ferriskey-cli-core/src/user.rs @@ -77,6 +77,8 @@ pub enum UserCommandError { UserNotFound(String), #[error("role '{0}' not found in realm")] RoleNotFound(String), + #[error("client '{0}' not found in realm")] + ClientNotFound(String), #[error("pass exactly one of '--password' or '--stdin'")] InvalidPasswordSource, #[error("failed to read password from stdin")] @@ -246,9 +248,33 @@ fn delete_user( render_message(output_format, &format!("user '{}' deleted", args.username)) } -fn resolve_role(client: &FerriskeyClient, realm: &str, role_name: &str) -> Result { +/// Resolve a client id (e.g. `my-app`) to the client's uuid, as required by +/// the client-role endpoints. +fn resolve_client_uuid(client: &FerriskeyClient, realm: &str, client_id: &str) -> Result { client - .list_realm_roles(realm)? + .get_client(realm, client_id)? + .and_then(|found| found.id) + .ok_or_else(|| UserCommandError::ClientNotFound(client_id.to_owned())) +} + +/// Resolve a role by name in the scope selected by `--client`: a specific +/// client's roles when given, realm roles otherwise. Role ids are unique +/// across both scopes, so the same `assign_user_role`/`remove_user_role` +/// calls work regardless of which scope resolved the id. +fn resolve_role( + client: &FerriskeyClient, + realm: &str, + role_name: &str, + client_id: Option<&str>, +) -> Result { + let roles = match client_id { + Some(client_id) => { + let uuid = resolve_client_uuid(client, realm, client_id)?; + client.list_client_roles(realm, &uuid)? + } + None => client.list_realm_roles(realm)?, + }; + roles .into_iter() .find(|r| r.name == role_name) .ok_or_else(|| UserCommandError::RoleNotFound(role_name.to_owned())) @@ -264,7 +290,7 @@ fn assign_role( let realm = resolve_realm(&context, args.realm)?; let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; - let role = resolve_role(&client, &realm, &args.role)?; + let role = resolve_role(&client, &realm, &args.role, args.client.as_deref())?; client.assign_user_role(&realm, &user.id, &role.id)?; render_message( output_format, @@ -285,7 +311,7 @@ fn remove_role( let realm = resolve_realm(&context, args.realm)?; let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; - let role = resolve_role(&client, &realm, &args.role)?; + let role = resolve_role(&client, &realm, &args.role, args.client.as_deref())?; client.remove_user_role(&realm, &user.id, &role.id)?; render_message( output_format,