feat: add group configuration (+)

will only check users of such group

also cleaned some code:
- Input doesn't need to be UTF-8 valid.
- Main fuction is dead-simple, very readable.
- Added some modularity.
- Add some docs on behavior details.
This commit is contained in:
2026-05-09 19:40:00 +02:00
parent d2a2baebc8
commit 0f736b1fc4
11 changed files with 224 additions and 81 deletions
Generated
+1
View File
@@ -62,6 +62,7 @@ name = "keycloak_pam"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"curl", "curl",
"libc",
"nonstick", "nonstick",
"serde", "serde",
"serde_json", "serde_json",
+1
View File
@@ -17,6 +17,7 @@ trim = true
[dependencies] [dependencies]
curl = { version = "0.4.49", default-features = false } curl = { version = "0.4.49", default-features = false }
libc = { version = "0.2.186", default-features = false }
nonstick = { version = "0.1.2", default-features = false, features = ["link"] } nonstick = { version = "0.1.2", default-features = false, features = ["link"] }
serde = { version = "1.0.228", default-features = false, features = ["derive"] } serde = { version = "1.0.228", default-features = false, features = ["derive"] }
serde_json = { version = "1.0.149", default-features = false, features = [ serde_json = { version = "1.0.149", default-features = false, features = [
+8 -2
View File
@@ -12,12 +12,18 @@ This is being worked on, refer to the test configuration at [`tests/nixos/config
Module can be easily configured with PAM arguments, for the sake of completeness refer to the source code. Module can be easily configured with PAM arguments, for the sake of completeness refer to the source code.
You can `cargo doc` to find the documentation for this at `keycloak_pam > Args`. You can `cargo doc` to find the documentation for this at `keycloak_pam > args > Args`.
# Restrictions # Restrictions and Guarantees
With the purpose of being tiny and minimal, the module uses libcurl for the few requests it makes, so HTTP/S and all networking behavior is offloaded to it. With the purpose of being tiny and minimal, the module uses libcurl for the few requests it makes, so HTTP/S and all networking behavior is offloaded to it.
This is also made in rust, which means default `String` and `&str` are guaranteed to be UTF-8 encoded and invalid UTF-8 input might fail. I did try to use OS and C strings for all input, but I recommend still avoiding non-UTF8 input.
The code should **NEVER** panic (aside from memory management issues), no `unwrap`, `expect` or such methods should ever be used, slice indexing is avoided and errors are gracefully managed.
To check the group, keycloak-pam uses libc's `getgrnam_r`, this takes a buffer we hardcoded to 4 KiB, if there happens to be a group bigger than this, keycloak-pam won't be able to handle and will fail explaining this.
# Behavior # Behavior
To authenticate users, the module uses the endpoint `{BASE_URL}/realms/{realm}/protocol/openid-connect/token` as a configured client. This needs a client ID and its secret that you need to get and configure per realm. To authenticate users, the module uses the endpoint `{BASE_URL}/realms/{realm}/protocol/openid-connect/token` as a configured client. This needs a client ID and its secret that you need to get and configure per realm.
+1 -1
View File
@@ -9,7 +9,7 @@ pkgs.rustPlatform.buildRustPackage {
src = pkgs.lib.cleanSource ./.; src = pkgs.lib.cleanSource ./.;
cargoHash = "sha256-Mpcd7OKJi7QAkwpUZd8aBfTsdQ+bRGUz5Dr36iJyi4g="; cargoHash = "sha256-We/E3T6SRZeTj3GZNySjhdlcg49v8FD8sGA5pyKSOXA=";
buildInputs = [ buildInputs = [
pkgs.pam pkgs.pam
+4 -6
View File
@@ -5,7 +5,7 @@
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
inputsFrom = [ inputsFrom = [
inputs'.error's-rust-overlay.devShells.rust-nightly inputs'.error's-rust-overlay.devShells.rust
]; ];
shellHook = '' shellHook = ''
@@ -20,20 +20,18 @@
done done
if [ -z "$QEMU_BRIDGE_HELPER_PATH" ]; then if [ -z "$QEMU_BRIDGE_HELPER_PATH" ]; then
printf "\033[1;33m%s\033[0m\n" \ printf "\033[1;33m%s%s\033[0m\n" \
"WARN: 'qemu-bridge-helper' not found, make sure it is installed and the nix shell hook is looking for it" >&2 "WARN: 'qemu-bridge-helper' not found, make sure it is " \
"installed and the nix shell hook is looking for it" >&2
fi fi
''; '';
packages = with pkgs; [ packages = with pkgs; [
# cargo
curl curl
delta delta
git git
nixfmt nixfmt
pam pam
# rust-analyzer
# rustc
]; ];
}; };
}; };
+36
View File
@@ -0,0 +1,36 @@
use std::ffi::CStr;
use crate::ext::CStrExt as _;
pub const DEFAULT_CONFIGURATION_PATH: &CStr = c"/etc/keycloak-pam.json";
/// # Arguments
///
/// - `config=`, optional, accepts a path to load json configuration from, defaults to [`DEFAULT_CONFIGURATION_PATH`]
/// - `debug`, optional, flag, ignores the PAM policy about silence and prints regardless
pub struct Args<'a> {
pub config: &'a CStr,
pub debug: bool,
}
impl<'a> Args<'a> {
#[must_use]
pub fn parse(args: &[&'a CStr]) -> Self {
let mut parsed_args = Args {
config: DEFAULT_CONFIGURATION_PATH,
debug: false,
};
for arg in args {
if arg == &c"debug" {
parsed_args.debug = true;
}
if let Some((b"config", path)) = arg.split_ch(b'=') {
parsed_args.config = path;
}
}
parsed_args
}
}
+19 -1
View File
@@ -1,4 +1,7 @@
use std::ffi::CStr; use std::{
ffi::{CStr, OsStr},
os::unix::ffi::OsStrExt as _,
};
pub trait SliceExt { pub trait SliceExt {
fn split_once_ch(&self, ch: u8) -> Option<(&[u8], &[u8])>; fn split_once_ch(&self, ch: u8) -> Option<(&[u8], &[u8])>;
@@ -13,10 +16,15 @@ impl SliceExt for [u8] {
} }
pub trait CStrExt { pub trait CStrExt {
fn to_os_str(&self) -> &OsStr;
fn split_ch(&self, ch: u8) -> Option<(&[u8], &CStr)>; fn split_ch(&self, ch: u8) -> Option<(&[u8], &CStr)>;
} }
impl CStrExt for CStr { impl CStrExt for CStr {
fn to_os_str(&self) -> &OsStr {
OsStr::from_bytes(self.to_bytes())
}
fn split_ch(&self, ch: u8) -> Option<(&[u8], &CStr)> { fn split_ch(&self, ch: u8) -> Option<(&[u8], &CStr)> {
let self_slice = self.to_bytes_with_nul(); let self_slice = self.to_bytes_with_nul();
@@ -27,3 +35,13 @@ impl CStrExt for CStr {
}) })
} }
} }
pub trait StrExt {
fn as_os_str(&self) -> &OsStr;
}
impl StrExt for str {
fn as_os_str(&self) -> &OsStr {
OsStr::from_bytes(self.as_bytes())
}
}
+31 -4
View File
@@ -1,20 +1,25 @@
use std::{ use std::{
ffi::OsStr, ffi::{CStr, CString, OsStr},
fs::File, fs::File,
io::{self}, io::{self},
os::unix::ffi::OsStrExt as _,
}; };
use curl::easy::{Easy, List}; use curl::easy::{Easy, List};
use serde::Deserialize; use serde::Deserialize;
use crate::{ext::CStrExt as _, libc as mylibc};
mod api; mod api;
#[allow(clippy::unsafe_derive_deserialize)] // seriously? -_-
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct KeycloakAPI { pub struct KeycloakAPI {
base_url: String, base_url: String,
default_realm: String, default_realm: String,
client_id: String, client_id: String,
client_secret: String, client_secret: String,
unix_group: CString,
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -68,13 +73,35 @@ impl KeycloakAPI {
Ok(serde_json::from_reader(f)?) Ok(serde_json::from_reader(f)?)
} }
pub fn check_credentials(&self, username: &str, password: &str) -> Result<(), LoginError> { pub fn should_skip(&self, username: &OsStr) -> Result<bool, mylibc::GetgrnamError> {
mylibc::getgrnam_use(&self.unix_group, |group| {
let mut members_ptr = group.gr_mem;
// SAFETY: The array pointer CANNOT be null, even if incremented.
while let member_cstr_ptr = unsafe { *members_ptr }
&& !member_cstr_ptr.is_null()
{
// SAFETY: its guaranteed to be a CStr and its non-null
let member_osstr = unsafe { CStr::from_ptr(member_cstr_ptr) }.to_os_str();
if member_osstr == username {
return false;
}
// SAFETY: if current pointer wasn't NULL, we can increase to the next one
members_ptr = unsafe { members_ptr.add(1) };
}
true
})
}
pub fn check_credentials(&self, username: &OsStr, password: &OsStr) -> Result<(), LoginError> {
let body = format!( let body = format!(
"grant_type=password&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&username={USERNAME}&password={PASSWORD}", "grant_type=password&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&username={USERNAME}&password={PASSWORD}",
CLIENT_ID = urlencoding::encode(&self.client_id), CLIENT_ID = urlencoding::encode(&self.client_id),
CLIENT_SECRET = urlencoding::encode(&self.client_secret), CLIENT_SECRET = urlencoding::encode(&self.client_secret),
USERNAME = urlencoding::encode(username), USERNAME = urlencoding::encode_binary(username.as_bytes()),
PASSWORD = urlencoding::encode(password), PASSWORD = urlencoding::encode_binary(password.as_bytes()),
); );
let (status_code, body) = curl_request( let (status_code, body) = curl_request(
+39 -61
View File
@@ -1,100 +1,78 @@
//! Privileged PAM module. //! Privileged PAM module.
use std::{ use std::ffi::CStr;
ffi::{CStr, OsStr},
os::unix::ffi::OsStrExt as _,
};
use nonstick::{ErrorCode, ModuleClient, PamModule, pam_export}; use nonstick::{ErrorCode, ModuleClient, PamModule, pam_export};
use keycloak::KeycloakAPI; use keycloak::KeycloakAPI;
use crate::args::Args;
use crate::ext::CStrExt as _; use crate::ext::CStrExt as _;
pub mod args;
pub mod ext; pub mod ext;
pub mod keycloak; pub mod keycloak;
pub mod libc;
pub const DEFAULT_CONFIGURATION_PATH: &CStr = c"/etc/keycloak-pam.json"; pub struct KeycloakPAM;
struct KeycloakPAM;
pam_export!(KeycloakPAM); pam_export!(KeycloakPAM);
/// # Arguments /// Conditionally prints the error and returns the value intouched.
/// ///
/// - `config=`, optional, accepts a path to load json configuration from, defaults to [`DEFAULT_CONFIGURATION_PATH`] /// All prints should use this macro to also format output.
/// - `debug`, optional, flag, ignores the PAM policy about silence and prints regardless macro_rules! error_print {
pub struct Args<'a> { ($expr:expr, $if_print:ident) => {{
config: &'a CStr, let expr = $expr;
debug: bool, if let Err(ref error) = expr
} && $if_print
{
impl<'a> Args<'a> { eprintln!("keycloak-pam: {error}");
fn parse(args: &[&'a CStr]) -> Self {
let mut parsed_args = Args {
config: DEFAULT_CONFIGURATION_PATH,
debug: false,
}; };
expr
for arg in args { }};
if arg == &c"debug" {
parsed_args.debug = true;
}
if let Some((b"config", path)) = arg.split_ch(b'=') {
parsed_args.config = path;
}
}
parsed_args
}
} }
/// Errors:
///
/// - [`ErrorCode::ConversationError`]: Something wrong converting types (e.g. UTF-8)
/// - [`ErrorCode::ServiceError`]: Something misconfigured in the service (e.g. bad configuration)
/// - [`ErrorCode::PermissionDenied`]: User not meant to be handled (e.g. user not member of
/// configured group)
/// - [`ErrorCode::AuthInfoUnavailable`]: Error fetch auth info (e.g. keycloak backend down)
/// - [`ErrorCode::AuthenticationError`]: Failed to authenticate (e.g. wrong credentials)
impl<M: ModuleClient> PamModule<M> for KeycloakPAM { impl<M: ModuleClient> PamModule<M> for KeycloakPAM {
fn authenticate( fn authenticate(
handle: &mut M, handle: &mut M,
args: Vec<&CStr>, args: Vec<&CStr>,
flags: nonstick::AuthnFlags, flags: nonstick::AuthnFlags,
) -> nonstick::Result<()> { ) -> nonstick::Result<()> {
fn osstr_to_str(p: &OsStr) -> nonstick::Result<&str> { use ErrorCode as E;
p.to_str().ok_or(ErrorCode::AuthInfoUnavailable)
}
let args = Args::parse(&args); let args = Args::parse(&args);
let username = handle.username(None)?; let username = handle.username(None)?;
let username = osstr_to_str(&username)?;
let password = handle.authtok(None)?; let password = handle.authtok(None)?;
let password = osstr_to_str(&password)?;
let no_silent_flag = (flags & nonstick::AuthnFlags::SILENT).is_empty(); let no_silent_flag = (flags & nonstick::AuthnFlags::SILENT).is_empty();
let can_print = no_silent_flag || args.debug; let can_print = no_silent_flag | args.debug;
let api_client = {
match KeycloakAPI::load_from_configuration(OsStr::from_bytes(args.config.to_bytes())) { let api_client = KeycloakAPI::load_from_configuration(args.config.to_os_str());
Ok(v) => v, let api_client = error_print!(api_client, can_print).map_err(|_| E::ServiceError)?;
Err(error) => {
if can_print {
eprintln!("{error}");
}
return Err(ErrorCode::ServiceError);
}
};
match api_client.check_credentials(username, password) { let should_skip = error_print!(api_client.should_skip(&username), can_print)
Ok(()) => Ok(()), .map_err(|_| E::ServiceError)?;
Err(error) => { if should_skip {
if can_print { return Err(E::PermissionDenied);
eprintln!("{error}");
} }
let err = match error { let checked_credentials = api_client.check_credentials(username.as_os_str(), &password);
keycloak::LoginError::Fetch(_) error_print!(checked_credentials, can_print).map_err(|error| match error {
| keycloak::LoginError::InvalidAPIResponse(_) => ErrorCode::ServiceError, keycloak::LoginError::Fetch(_) | keycloak::LoginError::InvalidAPIResponse(_) => {
keycloak::LoginError::InvalidLogin(_) => ErrorCode::CredentialsError, E::AuthInfoUnavailable
};
Err(err)
} }
keycloak::LoginError::InvalidLogin(_) => E::AuthenticationError,
})
} }
} }
+72
View File
@@ -0,0 +1,72 @@
use std::{ffi::CStr, mem::MaybeUninit};
use libc::{ERANGE, c_char};
#[derive(Debug, thiserror::Error)]
pub enum GetgrnamError {
#[error("no matching group record found")]
NoMatchingRecord,
#[error("insufficient buffer space. The group is too big to be handled by keycloak-pam")]
Range,
/// Ace variant for unexpected errors, error handling is shallow here
#[error("unexpected getgrnam_r error")]
Unexpected,
}
// TODO: Reorganise this function, error handing is shallow
/// # Safety
///
/// The function is completely safe to use but the use of `cb` requires unsafe:
///
/// - All raw pointers in there are only valid for the duration of the function as the buffer they
/// point to is local to this function.
pub fn getgrnam_use<T>(
cname: &CStr,
mut cb: impl FnMut(libc::group) -> T,
) -> Result<T, GetgrnamError> {
use GetgrnamError as E;
let name = cname.as_ptr();
let mut grp = MaybeUninit::uninit();
let mut buf = [c_char::from(0); 4096];
let mut result: MaybeUninit<*mut libc::group> = MaybeUninit::uninit();
// SAFETY: its just a C call, I'm respecting signature and assumptions
let ret = unsafe {
libc::getgrnam_r(
name,
grp.as_mut_ptr(),
buf.as_mut_ptr(),
buf.len(),
result.as_mut_ptr(),
)
};
// SAFETY: Pointers inside `grp` point may point to the buffer `buf` so they cannot be
// moved outside of this function.
// > On success, getgrnam_r() [..] return zero, and set `*result` to `grp`.
//
// > If no matching group record was found, these functions return 0 and store NULL in
// `*result`.
//
// > In case of error, an error number is returned, and NULL stored in `*result`.
//
// SAFETY: Either way, `result` is initialized to a nullable pointer.
let result_ptr = unsafe { result.assume_init() };
if ret == 0 && result_ptr == grp.as_mut_ptr() {
let gr = unsafe { grp.assume_init() };
Ok(cb(gr))
} else if result_ptr.is_null() {
Err(match ret {
0 => E::NoMatchingRecord,
ERANGE => E::Range,
_ => E::Unexpected,
})
} else {
// ret should be != 0 and result NULL, but any other case is also unexpected and I'm
// not doing much FFI error handling rn
Err(E::Unexpected)
}
}
+8 -2
View File
@@ -94,9 +94,14 @@ in
}; };
}; };
users.users.admin = { users = {
users.admin = {
password = config.services.keycloak.initialAdminPassword; password = config.services.keycloak.initialAdminPassword;
isNormalUser = true; isNormalUser = true;
extraGroups = [ "keycloak-login" ];
};
groups.keycloak-login = { };
}; };
services.keycloak = { services.keycloak = {
@@ -141,7 +146,8 @@ in
"base_url": "http://127.0.0.1:${toString config.services.keycloak.settings.http-port}", "base_url": "http://127.0.0.1:${toString config.services.keycloak.settings.http-port}",
"default_realm": "master", "default_realm": "master",
"client_id": "account", "client_id": "account",
"client_secret": "7bsrMrRuNKpqsxcOOJavWjEqek12ZCkj" "client_secret": "7bsrMrRuNKpqsxcOOJavWjEqek12ZCkj",
"unix_group": "keycloak-login"
} }
''; '';
}; };