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
105 changes: 105 additions & 0 deletions libs/ferriskey-cli-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub access_token_lifetime: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_token_lifetime: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_token_lifetime: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temporary_token_lifetime: Option<i64>,
}

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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions libs/ferriskey-cli-core/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
79 changes: 77 additions & 2 deletions libs/ferriskey-cli-core/src/import/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::collections::HashMap;

use ferriskey_cli_client::{
CreateClientRequest, CreateRedirectUriRequest, CreateRoleRequest, CreateUserRequest,
FerriskeyClient, FerriskeyClientError,
CreateWebOriginRequest, FerriskeyClient, FerriskeyClientError,
};
use reqwest::StatusCode;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}

Expand All @@ -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"))
)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
43 changes: 42 additions & 1 deletion libs/ferriskey-cli-core/src/import/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub post_logout_redirect_uris: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub web_origins: Vec<String>,
#[serde(default)]
pub device_authorization_grant_enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub require_pkce: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub access_token_lifetime: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token_lifetime: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id_token_lifetime: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub temporary_token_lifetime: Option<i64>,
/// Client-scoped roles.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<RoleBlueprint>,
}

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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions libs/ferriskey-cli-core/src/import/sources/keycloak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,16 @@ fn map_client(client: KcClient, roles: Vec<RoleBlueprint>) -> 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,
}
}
Expand Down
17 changes: 17 additions & 0 deletions libs/ferriskey-cli-core/src/import/sources/zitadel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}
Expand Down Expand Up @@ -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(),
}
}
Expand Down
Loading
Loading