From 64916be379260283f79f6a4f6c07b74a8f2e285f Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Thu, 3 Sep 2026 02:01:26 +0200 Subject: [PATCH] feat(import): extend ClientBlueprint with PKCE, device auth, origins, lifetimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post_logout_redirect_uris, web_origins, PKCE requirement, the Device Authorization grant toggle, and per-client token lifetimes were missing from ClientBlueprint, so an import couldn't reproduce them and they were lost on the next realm. Adds the corresponding client endpoints (POST .../post-logout-redirects, POST .../web-origins, PATCH .../clients/{id} for PKCE/lifetimes — the latter only settable after creation, unlike the rest of a client's fields) and applies them in apply.rs. All five stay optional so existing blueprint files keep importing unchanged. Keycloak/Zitadel sources default these fields rather than extracting them: Keycloak carries the equivalent settings under a free-form `attributes` map with Keycloak-specific keys, which is a separate scope of work. --- libs/ferriskey-cli-client/src/lib.rs | 105 ++++++++++++++++++ libs/ferriskey-cli-core/src/client.rs | 1 + libs/ferriskey-cli-core/src/import/apply.rs | 79 ++++++++++++- libs/ferriskey-cli-core/src/import/mod.rs | 43 ++++++- .../src/import/sources/keycloak.rs | 10 ++ .../src/import/sources/zitadel.rs | 17 +++ libs/ferriskey-cli-core/src/realm.rs | 9 ++ 7 files changed, 261 insertions(+), 3 deletions(-) diff --git a/libs/ferriskey-cli-client/src/lib.rs b/libs/ferriskey-cli-client/src/lib.rs index d8fb831..4e0231e 100644 --- a/libs/ferriskey-cli-client/src/lib.rs +++ b/libs/ferriskey-cli-client/src/lib.rs @@ -149,6 +149,7 @@ pub struct CreateClientRequest { pub protocol: String, pub public_client: bool, pub service_account_enabled: bool, + pub oauth_device_code_grant_enabled: bool, } #[derive(Debug, Clone, Deserialize)] @@ -231,6 +232,39 @@ pub struct CreateRedirectUriRequest { pub enabled: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct CreateWebOriginRequest { + pub value: String, +} + +/// Partial update of a client's PKCE requirement and token lifetimes. Only the +/// fields that are `Some` are sent. Applied via `PATCH`, unlike the rest of the +/// client's settings which are only settable at creation time. +#[derive(Debug, Clone, Default, Serialize)] +pub struct UpdateClientSettingsRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub require_pkce: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_token_lifetime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_token_lifetime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_lifetime: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temporary_token_lifetime: Option, +} + +impl UpdateClientSettingsRequest { + /// Returns true when no field is set (nothing to send). + pub fn is_empty(&self) -> bool { + self.require_pkce.is_none() + && self.access_token_lifetime.is_none() + && self.refresh_token_lifetime.is_none() + && self.id_token_lifetime.is_none() + && self.temporary_token_lifetime.is_none() + } +} + #[derive(Debug, Clone, Serialize)] pub struct SetPasswordRequest { pub value: String, @@ -574,6 +608,77 @@ impl FerriskeyClient { Ok(()) } + pub fn add_client_post_logout_redirect( + &self, + realm: &str, + client_uuid: &str, + request: &CreateRedirectUriRequest, + ) -> Result<(), FerriskeyClientError> { + let response = self + .http + .post(self.endpoint(&format!( + "realms/{realm}/clients/{client_uuid}/post-logout-redirects" + ))) + .bearer_auth(&self.token) + .json(request) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(FerriskeyClientError::Api { status, body }); + } + + Ok(()) + } + + pub fn add_client_web_origin( + &self, + realm: &str, + client_uuid: &str, + request: &CreateWebOriginRequest, + ) -> Result<(), FerriskeyClientError> { + let response = self + .http + .post(self.endpoint(&format!("realms/{realm}/clients/{client_uuid}/web-origins"))) + .bearer_auth(&self.token) + .json(request) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(FerriskeyClientError::Api { status, body }); + } + + Ok(()) + } + + /// Update a client's PKCE requirement / token lifetimes. Unlike most of a + /// client's settings (only settable at creation), these are only settable + /// via this PATCH. + pub fn update_client_settings( + &self, + realm: &str, + client_uuid: &str, + request: &UpdateClientSettingsRequest, + ) -> Result<(), FerriskeyClientError> { + let response = self + .http + .patch(self.endpoint(&format!("realms/{realm}/clients/{client_uuid}"))) + .bearer_auth(&self.token) + .json(request) + .send()?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().unwrap_or_default(); + return Err(FerriskeyClientError::Api { status, body }); + } + + Ok(()) + } + pub fn assign_user_role( &self, realm: &str, diff --git a/libs/ferriskey-cli-core/src/client.rs b/libs/ferriskey-cli-core/src/client.rs index b27104a..e75edc0 100644 --- a/libs/ferriskey-cli-core/src/client.rs +++ b/libs/ferriskey-cli-core/src/client.rs @@ -309,6 +309,7 @@ fn build_create_client_request(args: ClientCreateArgs) -> CreateClientRequest { public_client, service_account_enabled, direct_access_grants_enabled: args.direct_access_grants_enabled, + oauth_device_code_grant_enabled: false, } } diff --git a/libs/ferriskey-cli-core/src/import/apply.rs b/libs/ferriskey-cli-core/src/import/apply.rs index 4caeadb..cbb5bfa 100644 --- a/libs/ferriskey-cli-core/src/import/apply.rs +++ b/libs/ferriskey-cli-core/src/import/apply.rs @@ -10,7 +10,7 @@ use std::collections::HashMap; use ferriskey_cli_client::{ CreateClientRequest, CreateRedirectUriRequest, CreateRoleRequest, CreateUserRequest, - FerriskeyClient, FerriskeyClientError, + CreateWebOriginRequest, FerriskeyClient, FerriskeyClientError, }; use reqwest::StatusCode; @@ -40,6 +40,17 @@ pub fn apply_blueprint( report.roles_created = blueprint.roles.len(); report.clients_created = blueprint.clients.len(); report.redirects_created = blueprint.clients.iter().map(|c| c.redirect_uris.len()).sum(); + report.post_logout_redirects_created = blueprint + .clients + .iter() + .map(|c| c.post_logout_redirect_uris.len()) + .sum(); + report.web_origins_created = blueprint.clients.iter().map(|c| c.web_origins.len()).sum(); + report.client_settings_applied = blueprint + .clients + .iter() + .filter(|c| !c.to_settings_request().is_empty()) + .count(); report.client_roles_created = blueprint.clients.iter().map(|c| c.roles.len()).sum(); report.users_created = blueprint.users.len(); report.role_assignments = blueprint.users.iter().map(|u| u.roles.len()).sum(); @@ -131,6 +142,47 @@ pub fn apply_blueprint( } } + for uri in &client_bp.post_logout_redirect_uris { + let request = CreateRedirectUriRequest { + value: uri.clone(), + enabled: true, + }; + match client.add_client_post_logout_redirect(realm, &client_uuid, &request) { + Ok(()) => report.post_logout_redirects_created += 1, + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "post-logout redirect '{uri}' already exists on client '{}'", + client_bp.client_id + )); + } + Err(e) => return Err(e.into()), + } + } + + for origin in &client_bp.web_origins { + let request = CreateWebOriginRequest { + value: origin.clone(), + }; + match client.add_client_web_origin(realm, &client_uuid, &request) { + Ok(()) => report.web_origins_created += 1, + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "web origin '{origin}' already exists on client '{}'", + client_bp.client_id + )); + } + Err(e) => return Err(e.into()), + } + } + + let settings_request = client_bp.to_settings_request(); + if !settings_request.is_empty() { + client.update_client_settings(realm, &client_uuid, &settings_request)?; + report.client_settings_applied += 1; + } + for role in &client_bp.roles { match client.create_client_role(realm, &client_uuid, &role_request(role)) { Ok(_) => report.client_roles_created += 1, @@ -265,6 +317,7 @@ fn client_request(client_bp: &ClientBlueprint) -> CreateClientRequest { protocol: client_bp.protocol.clone(), public_client: client_bp.public_client, service_account_enabled: client_bp.service_account_enabled, + oauth_device_code_grant_enabled: client_bp.device_authorization_grant_enabled, } } @@ -289,7 +342,9 @@ fn is_conflict(error: &FerriskeyClientError) -> bool { error, FerriskeyClientError::Api { status, body } if *status == StatusCode::CONFLICT - || (*status == StatusCode::BAD_REQUEST && body.to_lowercase().contains("exist")) + || (*status == StatusCode::BAD_REQUEST + && (body.to_lowercase().contains("exist") + || body.to_lowercase().contains("already registered"))) || (*status == StatusCode::INTERNAL_SERVER_ERROR && body.to_lowercase().contains("unique constraint")) ) @@ -323,6 +378,14 @@ mod tests { service_account_enabled: false, direct_access_grants_enabled: false, redirect_uris: vec!["https://a/*".to_owned(), "https://b/*".to_owned()], + post_logout_redirect_uris: vec!["https://a/bye".to_owned()], + web_origins: vec!["https://a".to_owned()], + device_authorization_grant_enabled: false, + require_pkce: Some(true), + access_token_lifetime: None, + refresh_token_lifetime: None, + id_token_lifetime: None, + temporary_token_lifetime: None, roles: vec![RoleBlueprint { name: "viewer".to_owned(), ..Default::default() @@ -351,6 +414,9 @@ mod tests { assert_eq!(report.roles_created, 1); assert_eq!(report.clients_created, 1); assert_eq!(report.redirects_created, 2); + assert_eq!(report.post_logout_redirects_created, 1); + assert_eq!(report.web_origins_created, 1); + assert_eq!(report.client_settings_applied, 1); assert_eq!(report.client_roles_created, 1); assert_eq!(report.users_created, 1); assert_eq!(report.role_assignments, 1); @@ -386,6 +452,15 @@ mod tests { ))); } + #[test] + fn is_conflict_recognizes_400_web_origin_already_registered() { + // Observed live: a duplicate web origin doesn't say "exist" at all. + assert!(is_conflict(&api_error( + StatusCode::BAD_REQUEST, + "Invalid web origin: this origin is already registered for the client" + ))); + } + #[test] fn is_conflict_recognizes_500_unique_constraint_violation() { // A raw Postgres unique-constraint violation surfaced as a 500 by diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index e5b642a..ca423e5 100644 --- a/libs/ferriskey-cli-core/src/import/mod.rs +++ b/libs/ferriskey-cli-core/src/import/mod.rs @@ -8,7 +8,9 @@ pub mod apply; pub mod sources; -use ferriskey_cli_client::{FerriskeyClientError, UpdateRealmSettingsRequest}; +use ferriskey_cli_client::{ + FerriskeyClientError, UpdateClientSettingsRequest, UpdateRealmSettingsRequest, +}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -110,11 +112,39 @@ pub struct ClientBlueprint { pub direct_access_grants_enabled: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub redirect_uris: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub post_logout_redirect_uris: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub web_origins: Vec, + #[serde(default)] + pub device_authorization_grant_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_pkce: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_token_lifetime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_token_lifetime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id_token_lifetime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temporary_token_lifetime: Option, /// Client-scoped roles. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub roles: Vec, } +impl ClientBlueprint { + pub fn to_settings_request(&self) -> UpdateClientSettingsRequest { + UpdateClientSettingsRequest { + require_pkce: self.require_pkce, + access_token_lifetime: self.access_token_lifetime, + refresh_token_lifetime: self.refresh_token_lifetime, + id_token_lifetime: self.id_token_lifetime, + temporary_token_lifetime: self.temporary_token_lifetime, + } + } +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct UserBlueprint { pub username: String, @@ -160,6 +190,9 @@ pub struct ImportReport { pub roles_created: usize, pub clients_created: usize, pub redirects_created: usize, + pub post_logout_redirects_created: usize, + pub web_origins_created: usize, + pub client_settings_applied: usize, pub client_roles_created: usize, pub users_created: usize, pub role_assignments: usize, @@ -239,6 +272,14 @@ mod tests { service_account_enabled: false, direct_access_grants_enabled: false, redirect_uris: vec!["https://app.acme.test/*".to_owned()], + post_logout_redirect_uris: vec!["https://app.acme.test/bye".to_owned()], + web_origins: vec!["https://app.acme.test".to_owned()], + device_authorization_grant_enabled: true, + require_pkce: Some(true), + access_token_lifetime: Some(300), + refresh_token_lifetime: None, + id_token_lifetime: None, + temporary_token_lifetime: None, roles: vec![], }], users: vec![UserBlueprint { diff --git a/libs/ferriskey-cli-core/src/import/sources/keycloak.rs b/libs/ferriskey-cli-core/src/import/sources/keycloak.rs index ea5bf36..775ec12 100644 --- a/libs/ferriskey-cli-core/src/import/sources/keycloak.rs +++ b/libs/ferriskey-cli-core/src/import/sources/keycloak.rs @@ -197,6 +197,16 @@ fn map_client(client: KcClient, roles: Vec) -> ClientBlueprint { service_account_enabled: client.service_accounts_enabled.unwrap_or(false), direct_access_grants_enabled: client.direct_access_grants_enabled.unwrap_or(false), redirect_uris: client.redirect_uris.unwrap_or_default(), + // Keycloak carries these under a free-form `attributes` map with + // Keycloak-specific keys, not a plain field — not extracted here. + post_logout_redirect_uris: Vec::new(), + web_origins: Vec::new(), + device_authorization_grant_enabled: false, + require_pkce: None, + access_token_lifetime: None, + refresh_token_lifetime: None, + id_token_lifetime: None, + temporary_token_lifetime: None, roles, } } diff --git a/libs/ferriskey-cli-core/src/import/sources/zitadel.rs b/libs/ferriskey-cli-core/src/import/sources/zitadel.rs index 14c6e48..952340d 100644 --- a/libs/ferriskey-cli-core/src/import/sources/zitadel.rs +++ b/libs/ferriskey-cli-core/src/import/sources/zitadel.rs @@ -197,6 +197,15 @@ fn map_app(app: ZitadelApp) -> ClientBlueprint { service_account_enabled: false, direct_access_grants_enabled: false, redirect_uris: oidc.redirect_uris.unwrap_or_default(), + // Not carried over from Zitadel — not extracted here. + post_logout_redirect_uris: Vec::new(), + web_origins: Vec::new(), + device_authorization_grant_enabled: false, + require_pkce: None, + access_token_lifetime: None, + refresh_token_lifetime: None, + id_token_lifetime: None, + temporary_token_lifetime: None, roles: Vec::new(), } } @@ -227,6 +236,14 @@ fn map_service_account(user_name: String, machine: Machine) -> ClientBlueprint { service_account_enabled: true, direct_access_grants_enabled: false, redirect_uris: Vec::new(), + post_logout_redirect_uris: Vec::new(), + web_origins: Vec::new(), + device_authorization_grant_enabled: false, + require_pkce: None, + access_token_lifetime: None, + refresh_token_lifetime: None, + id_token_lifetime: None, + temporary_token_lifetime: None, roles: Vec::new(), } } diff --git a/libs/ferriskey-cli-core/src/realm.rs b/libs/ferriskey-cli-core/src/realm.rs index a360805..7284086 100644 --- a/libs/ferriskey-cli-core/src/realm.rs +++ b/libs/ferriskey-cli-core/src/realm.rs @@ -408,6 +408,15 @@ fn render_reports(output_format: &str, reports: &[ImportReport]) -> Result<()> { println!(" roles created: {}", report.roles_created); println!(" clients created: {}", report.clients_created); println!(" redirect uris added: {}", report.redirects_created); + println!( + " post-logout uris added: {}", + report.post_logout_redirects_created + ); + println!(" web origins added: {}", report.web_origins_created); + println!( + " client settings applied: {}", + report.client_settings_applied + ); println!(" client roles created: {}", report.client_roles_created); println!(" users created: {}", report.users_created); println!(" role assignments: {}", report.role_assignments);