From f00f73a1749402acc74453df079b012e0a73dac1 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Wed, 2 Sep 2026 23:27:36 +0200 Subject: [PATCH] fix(session): authenticate against the context's home realm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit client.rs and user.rs authenticated against the resolved target realm (--realm or context default) instead of the context's configured home realm, where the OAuth client is actually registered. Any command whose target realm differed from the client's home realm failed: the token exchange (or the cached-credentials lookup) was attempted against the wrong realm. realm.rs already authenticated correctly (always via context.realm, independent of the operation's target realm) — client.rs and user.rs now follow the same auth_client(context) pattern. Root cause of #21. --- libs/ferriskey-cli-core/src/client.rs | 43 +++++++++++++++++++++++---- libs/ferriskey-cli-core/src/user.rs | 38 ++++++++++++++++++----- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/libs/ferriskey-cli-core/src/client.rs b/libs/ferriskey-cli-core/src/client.rs index 84079c4..edd93e4 100644 --- a/libs/ferriskey-cli-core/src/client.rs +++ b/libs/ferriskey-cli-core/src/client.rs @@ -1,5 +1,5 @@ use ferriskey_cli_client::{ - ClientRepresentation, CreateClientRequest, CreatedClient, FerriskeyClientError, + ClientRepresentation, CreateClientRequest, CreatedClient, FerriskeyClient, FerriskeyClientError, }; use ferriskey_cli_commands::{ ClientCommand, ClientCreateArgs, ClientDeleteArgs, ClientGetArgs, ClientListArgs, @@ -56,6 +56,10 @@ pub enum ClientCommandError { "realm is required: pass '--realm' or configure a default realm on the selected context" )] MissingRealm, + #[error( + "auth realm is required: configure a default realm on the selected context ('ferris-ctl context add --realm ')" + )] + MissingAuthRealm, #[error("unsupported output format: {0}")] UnsupportedOutputFormat(String), #[error("failed to serialize JSON output")] @@ -116,7 +120,7 @@ fn delete_client( )?; let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm.clone())?; - let client = session::authenticated_client(&context, &realm)?; + let client = auth_client(&context)?; let found = client .get_client(&realm, &args.client_id)? .ok_or_else(|| ClientCommandError::ClientNotFound(args.client_id.clone()))?; @@ -139,7 +143,7 @@ fn get_client( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm.clone())?; - let client = session::authenticated_client(&context, &realm)?; + let client = auth_client(&context)?; let result = client .get_client(&realm, &args.client_id)? .ok_or_else(|| ClientCommandError::ClientNotFound(args.client_id.clone()))?; @@ -155,7 +159,7 @@ fn list_clients( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm.clone())?; - let client = session::authenticated_client(&context, &realm)?; + let client = auth_client(&context)?; let clients = client.list_clients(&realm)?; let views = clients.into_iter().map(to_view).collect::>(); @@ -170,7 +174,7 @@ fn create_client( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm.clone())?; - let client = session::authenticated_client(&context, &realm)?; + let client = auth_client(&context)?; let request = build_create_client_request(args); let created = client.create_client(&realm, &request)?; @@ -211,6 +215,18 @@ fn resolve_realm(context: &StoredContext, realm: Option) -> Result Result { + let auth_realm = context + .realm + .as_deref() + .ok_or(ClientCommandError::MissingAuthRealm)?; + Ok(session::authenticated_client(context, auth_realm)?) +} + fn to_view(client: ClientRepresentation) -> ClientView { ClientView { id: client.id.unwrap_or_default(), @@ -458,6 +474,23 @@ mod tests { use crate::config::StoredContext; use std::collections::BTreeMap; + #[test] + fn auth_client_requires_realm_on_context() { + // Regression: auth_client must authenticate against the context's + // home realm, never a command's target `--realm` (which it doesn't + // even take as a parameter) — a context without a home realm can't + // authenticate at all, regardless of any target realm resolved + // elsewhere. + let context = StoredContext { + url: "http://localhost:3333".to_owned(), + client_id: "cli".to_owned(), + client_secret: Some("secret".to_owned()), + realm: None, + }; + let err = auth_client(&context).expect_err("missing realm should error"); + assert!(matches!(err, ClientCommandError::MissingAuthRealm)); + } + #[test] fn select_context_uses_active_context_by_default() { let mut contexts = BTreeMap::new(); diff --git a/libs/ferriskey-cli-core/src/user.rs b/libs/ferriskey-cli-core/src/user.rs index 0df0c66..a5d266b 100644 --- a/libs/ferriskey-cli-core/src/user.rs +++ b/libs/ferriskey-cli-core/src/user.rs @@ -57,6 +57,10 @@ pub enum UserCommandError { "realm is required: pass '--realm' or configure a default realm on the selected context" )] MissingRealm, + #[error( + "auth realm is required: configure a default realm on the selected context ('ferris-ctl context add --realm ')" + )] + MissingAuthRealm, #[error("user '{0}' not found")] UserNotFound(String), #[error("role '{0}' not found in realm")] @@ -114,8 +118,16 @@ fn resolve_realm(context: &StoredContext, realm: Option) -> Result Result { - Ok(session::authenticated_client(context, realm)?) +/// Authenticate against the context's home realm — where the context's +/// client_id/client_secret are registered — regardless of which realm the +/// command targets via `--realm`. The server, not the CLI, decides whether +/// the resulting token can act on the target realm. +fn auth_client(context: &StoredContext) -> Result { + let auth_realm = context + .realm + .as_deref() + .ok_or(UserCommandError::MissingAuthRealm)?; + Ok(session::authenticated_client(context, auth_realm)?) } fn find_user(client: &FerriskeyClient, realm: &str, username: &str) -> Result { @@ -145,7 +157,7 @@ fn list_users( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let users = client.list_users(&realm)?; let views: Vec = users.into_iter().map(to_view).collect(); render_user_list(output_format, &views) @@ -159,7 +171,7 @@ fn get_user( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; render_user(output_format, to_view(user)) } @@ -172,7 +184,7 @@ fn create_user( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let request = CreateUserRequest { username: args.username, firstname: args.firstname, @@ -196,7 +208,7 @@ fn delete_user( )?; let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; client.delete_user(&realm, &user.id)?; render_message(output_format, &format!("user '{}' deleted", args.username)) @@ -210,7 +222,7 @@ fn assign_role( ) -> Result<()> { let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm)?; - let client = authenticate(&context, &realm)?; + let client = auth_client(&context)?; let user = find_user(&client, &realm, &args.username)?; let role = client .list_realm_roles(&realm)? @@ -355,6 +367,18 @@ mod tests { } } + #[test] + fn auth_client_requires_realm_on_context() { + // Regression: auth_client must authenticate against the context's + // home realm, never a command's target `--realm` (which it doesn't + // even take as a parameter) — a context without a home realm can't + // authenticate at all, regardless of any target realm resolved + // elsewhere. + let context = make_context(None); + let err = auth_client(&context).expect_err("missing realm should error"); + assert!(matches!(err, UserCommandError::MissingAuthRealm)); + } + #[test] fn resolve_realm_prefers_explicit_argument() { let context = make_context(Some("master"));