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
67 changes: 67 additions & 0 deletions libs/ferriskey-cli-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, FerriskeyClientError> {
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<Vec<UserRepresentation>, FerriskeyClientError> {
self.get_list(&self.endpoint(&format!("realms/{realm}/users")))
}
Expand Down Expand Up @@ -973,3 +986,57 @@ enum ListPayload<T> {
struct DataEnvelope<T> {
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<ClientSecretPayload> },
}

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");
}
}
13 changes: 13 additions & 0 deletions libs/ferriskey-cli-commands/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,
}

/// Supported client types.
#[derive(Clone, Debug, ValueEnum)]
pub enum ClientType {
Expand Down
2 changes: 1 addition & 1 deletion libs/ferriskey-cli-commands/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 14 additions & 6 deletions libs/ferriskey-cli-commands/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,50 @@ 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),
/// Set a user's password.
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<String>,

/// Assign a role of this client instead of a realm role.
#[arg(long)]
pub client: Option<String>,
}

/// 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<String>,

/// Remove a role of this client instead of a realm role.
#[arg(long)]
pub client: Option<String>,
}

/// Arguments for listing a user's realm roles.
Expand Down
28 changes: 27 additions & 1 deletion libs/ferriskey-cli-core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
}
}

Expand Down Expand Up @@ -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<StoredContext>,
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>,
Expand Down
17 changes: 16 additions & 1 deletion libs/ferriskey-cli-core/src/import/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
///
Expand Down Expand Up @@ -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(),
Expand Down
11 changes: 11 additions & 0 deletions libs/ferriskey-cli-core/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClientSecretEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct ClientSecretEntry {
pub client_id: String,
pub secret: String,
}

#[derive(Debug, Error)]
pub enum ImportError {
#[error(transparent)]
Expand Down
6 changes: 6 additions & 0 deletions libs/ferriskey-cli-core/src/realm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 30 additions & 4 deletions libs/ferriskey-cli-core/src/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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<CreatedRole> {
/// 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<String> {
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<CreatedRole> {
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()))
Expand All @@ -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,
Expand All @@ -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,
Expand Down