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
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
Loading