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..35023a8 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(); @@ -107,11 +118,16 @@ pub fn apply_blueprint( } } - // 4. Clients, with their redirect URIs and client-scoped roles. + // 4. Clients, with their redirect URIs and client-scoped roles. Track + // client_id -> uuid and (client_id, role name) -> role id so client roles + // can be assigned to users afterward, same as realm roles above. + let mut client_uuids: HashMap = HashMap::new(); + let mut client_role_ids: HashMap<(String, String), String> = HashMap::new(); for client_bp in &blueprint.clients { let Some(client_uuid) = resolve_client(client, realm, client_bp, &mut report)? else { continue; }; + client_uuids.insert(client_bp.client_id.clone(), client_uuid.clone()); for uri in &client_bp.redirect_uris { let request = CreateRedirectUriRequest { @@ -131,9 +147,54 @@ 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, + Ok(created) => { + client_role_ids + .insert((client_bp.client_id.clone(), created.name), created.id); + report.client_roles_created += 1; + } Err(e) if is_conflict(&e) => { report.already_present += 1; report.warnings.push(format!( @@ -146,6 +207,32 @@ pub fn apply_blueprint( } } + // Backfill ids for client roles referenced by users but skipped above + // (already existing) — same rationale as the realm-role backfill. + let missing_client_role_ref = blueprint.users.iter().flat_map(|u| &u.roles).any(|spec| { + matches!( + parse_role_ref(spec), + RoleRef::Client { client_id, role } + if !client_role_ids.contains_key(&(client_id.to_owned(), role.to_owned())) + ) + }); + if missing_client_role_ref { + for (client_id, uuid) in &client_uuids { + match client.list_client_roles(realm, uuid) { + Ok(existing) => { + for role in existing { + client_role_ids + .entry((client_id.clone(), role.name)) + .or_insert(role.id); + } + } + Err(e) => report.warnings.push(format!( + "could not list roles of client '{client_id}' for assignment: {e}" + )), + } + } + } + // 5. Users, with realm-role assignments. for user in &blueprint.users { let user_id = match client.create_user(realm, &user_request(user)) { @@ -164,23 +251,43 @@ pub fn apply_blueprint( }; let Some(user_id) = user_id else { continue }; - for role_name in &user.roles { - match role_ids.get(role_name) { - Some(role_id) => match client.assign_user_role(realm, &user_id, role_id) { - Ok(()) => report.role_assignments += 1, - Err(e) if is_conflict(&e) => { - report.already_present += 1; + for role_spec in &user.roles { + let role_id = match parse_role_ref(role_spec) { + RoleRef::Realm(name) => match role_ids.get(name) { + Some(role_id) => Some(role_id), + None => { report.warnings.push(format!( - "user '{}' already has role '{role_name}'", + "role '{name}' not found, cannot assign it to user '{}'", user.username )); + None } - Err(e) => return Err(e.into()), }, - None => report.warnings.push(format!( - "role '{role_name}' not found, cannot assign it to user '{}'", - user.username - )), + RoleRef::Client { client_id, role } => { + match client_role_ids.get(&(client_id.to_owned(), role.to_owned())) { + Some(role_id) => Some(role_id), + None => { + return Err(ImportError::UnresolvedClientRole { + client_id: client_id.to_owned(), + role: role.to_owned(), + username: user.username.clone(), + }); + } + } + } + }; + + let Some(role_id) = role_id else { continue }; + match client.assign_user_role(realm, &user_id, role_id) { + Ok(()) => report.role_assignments += 1, + Err(e) if is_conflict(&e) => { + report.already_present += 1; + report.warnings.push(format!( + "user '{}' already has role '{role_spec}'", + user.username + )); + } + Err(e) => return Err(e.into()), } } } @@ -247,6 +354,21 @@ fn resolve_existing_user( } } +/// A `UserBlueprint.roles` entry: a plain name for a realm role, or +/// `client_id:role_name` for a role scoped to that client. +#[derive(Debug, PartialEq, Eq)] +enum RoleRef<'a> { + Realm(&'a str), + Client { client_id: &'a str, role: &'a str }, +} + +fn parse_role_ref(spec: &str) -> RoleRef<'_> { + match spec.split_once(':') { + Some((client_id, role)) => RoleRef::Client { client_id, role }, + None => RoleRef::Realm(spec), + } +} + fn role_request(role: &RoleBlueprint) -> CreateRoleRequest { CreateRoleRequest { name: role.name.clone(), @@ -265,6 +387,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 +412,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 +448,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 +484,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 +522,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 @@ -408,4 +553,20 @@ mod tests { fn is_conflict_rejects_unrelated_400() { assert!(!is_conflict(&api_error(StatusCode::BAD_REQUEST, "invalid input"))); } + + #[test] + fn parse_role_ref_plain_name_is_realm_role() { + assert_eq!(parse_role_ref("admin"), RoleRef::Realm("admin")); + } + + #[test] + fn parse_role_ref_qualified_name_is_client_role() { + assert_eq!( + parse_role_ref("myapp:viewer"), + RoleRef::Client { + client_id: "myapp", + role: "viewer" + } + ); + } } diff --git a/libs/ferriskey-cli-core/src/import/mod.rs b/libs/ferriskey-cli-core/src/import/mod.rs index e5b642a..db22120 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, @@ -126,7 +156,8 @@ pub struct UserBlueprint { pub lastname: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub email_verified: Option, - /// Realm role names to assign to this user. + /// Roles to assign to this user: a plain name for a realm role, or + /// `client_id:role_name` for a role scoped to that client. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub roles: Vec, } @@ -160,6 +191,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, @@ -176,6 +210,15 @@ pub enum ImportError { Config(#[from] crate::config::ConfigError), #[error("unknown source '{0}' (see `ferris-ctl source list`)")] UnknownSourceRef(String), + #[error( + "client role '{client_id}:{role}' referenced by user '{username}' was not found — \ + define it under that client's `roles` in the blueprint" + )] + UnresolvedClientRole { + client_id: String, + role: String, + username: String, + }, #[error("provide either --from or --source-ref ")] NoSourceSpecified, #[error( @@ -239,6 +282,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);