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
+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 {
fn split_once_ch(&self, ch: u8) -> Option<(&[u8], &[u8])>;
@@ -13,10 +16,15 @@ impl SliceExt for [u8] {
}
pub trait CStrExt {
fn to_os_str(&self) -> &OsStr;
fn split_ch(&self, ch: u8) -> Option<(&[u8], &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)> {
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::{
ffi::OsStr,
ffi::{CStr, CString, OsStr},
fs::File,
io::{self},
os::unix::ffi::OsStrExt as _,
};
use curl::easy::{Easy, List};
use serde::Deserialize;
use crate::{ext::CStrExt as _, libc as mylibc};
mod api;
#[allow(clippy::unsafe_derive_deserialize)] // seriously? -_-
#[derive(Debug, Deserialize)]
pub struct KeycloakAPI {
base_url: String,
default_realm: String,
client_id: String,
client_secret: String,
unix_group: CString,
}
#[derive(Debug, thiserror::Error)]
@@ -68,13 +73,35 @@ impl KeycloakAPI {
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!(
"grant_type=password&client_id={CLIENT_ID}&client_secret={CLIENT_SECRET}&username={USERNAME}&password={PASSWORD}",
CLIENT_ID = urlencoding::encode(&self.client_id),
CLIENT_SECRET = urlencoding::encode(&self.client_secret),
USERNAME = urlencoding::encode(username),
PASSWORD = urlencoding::encode(password),
USERNAME = urlencoding::encode_binary(username.as_bytes()),
PASSWORD = urlencoding::encode_binary(password.as_bytes()),
);
let (status_code, body) = curl_request(
+41 -63
View File
@@ -1,100 +1,78 @@
//! Privileged PAM module.
use std::{
ffi::{CStr, OsStr},
os::unix::ffi::OsStrExt as _,
};
use std::ffi::CStr;
use nonstick::{ErrorCode, ModuleClient, PamModule, pam_export};
use keycloak::KeycloakAPI;
use crate::args::Args;
use crate::ext::CStrExt as _;
pub mod args;
pub mod ext;
pub mod keycloak;
pub mod libc;
pub const DEFAULT_CONFIGURATION_PATH: &CStr = c"/etc/keycloak-pam.json";
struct KeycloakPAM;
pub struct 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`]
/// - `debug`, optional, flag, ignores the PAM policy about silence and prints regardless
pub struct Args<'a> {
config: &'a CStr,
debug: bool,
}
impl<'a> Args<'a> {
fn parse(args: &[&'a CStr]) -> Self {
let mut parsed_args = Args {
config: DEFAULT_CONFIGURATION_PATH,
debug: false,
/// All prints should use this macro to also format output.
macro_rules! error_print {
($expr:expr, $if_print:ident) => {{
let expr = $expr;
if let Err(ref error) = expr
&& $if_print
{
eprintln!("keycloak-pam: {error}");
};
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
}
expr
}};
}
/// 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 {
fn authenticate(
handle: &mut M,
args: Vec<&CStr>,
flags: nonstick::AuthnFlags,
) -> nonstick::Result<()> {
fn osstr_to_str(p: &OsStr) -> nonstick::Result<&str> {
p.to_str().ok_or(ErrorCode::AuthInfoUnavailable)
}
use ErrorCode as E;
let args = Args::parse(&args);
let username = handle.username(None)?;
let username = osstr_to_str(&username)?;
let password = handle.authtok(None)?;
let password = osstr_to_str(&password)?;
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())) {
Ok(v) => v,
Err(error) => {
if can_print {
eprintln!("{error}");
}
return Err(ErrorCode::ServiceError);
}
};
{
let api_client = KeycloakAPI::load_from_configuration(args.config.to_os_str());
let api_client = error_print!(api_client, can_print).map_err(|_| E::ServiceError)?;
match api_client.check_credentials(username, password) {
Ok(()) => Ok(()),
Err(error) => {
if can_print {
eprintln!("{error}");
}
let err = match error {
keycloak::LoginError::Fetch(_)
| keycloak::LoginError::InvalidAPIResponse(_) => ErrorCode::ServiceError,
keycloak::LoginError::InvalidLogin(_) => ErrorCode::CredentialsError,
};
Err(err)
let should_skip = error_print!(api_client.should_skip(&username), can_print)
.map_err(|_| E::ServiceError)?;
if should_skip {
return Err(E::PermissionDenied);
}
let checked_credentials = api_client.check_credentials(username.as_os_str(), &password);
error_print!(checked_credentials, can_print).map_err(|error| match error {
keycloak::LoginError::Fetch(_) | keycloak::LoginError::InvalidAPIResponse(_) => {
E::AuthInfoUnavailable
}
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)
}
}