From e00e98129bd544117365ecce24bc4612aacdf05c Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 27 Mar 2026 23:00:55 +0100 Subject: [PATCH 1/5] pam: start tests --- .github/workflows/test.yml | 36 ++++++++- .gitignore | 1 + Makefile | 18 +++-- pam/src/auth.rs | 92 ++++++++++++++++------ pam/src/auth/interactive.rs | 86 ++++++++++++++++++++ pam/src/auth/token.rs | 98 +++++++++++++++++++---- pam/src/session_data.rs | 152 +++++++++++++++++++++++++++--------- 7 files changed, 404 insertions(+), 79 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f88b8fcc..efc17b2f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ permissions: jobs: test_go: - name: Test + name: Test (go) strategy: fail-fast: false matrix: @@ -66,6 +66,40 @@ jobs: - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v8 with: args: --timeout 5000s + test_rs: + name: Test (rs) + strategy: + fail-fast: false + matrix: + platform: + - ubuntu-24.04 + - ubuntu-24.04-arm + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5 + - run: | + sudo apt-get update + sudo apt-get install -y libpam0g-dev libudev-dev + - run: make test-rs + - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 + if: ${{ always() }} + with: + paths: ${{ github.workspace }}/coverage-rs.xml + show: "fail" + - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 + if: ${{ always() }} + with: + use_oidc: true + flags: go-${{ matrix.platform }} + files: ${{ github.workspace }}/coverage-rs.xml + - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 + if: ${{ always() }} + with: + use_oidc: true + flags: go-${{ matrix.platform }} + files: ${{ github.workspace }}/coverage-rs.xml + report_type: test_results lint_rs: name: Lint (rust) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 00108531..8990e5e7 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ terraform.rc terraform examples_local coverage.* +coverage-rs.* coverage_in_container.* junit.xml authentik_main diff --git a/Makefile b/Makefile index f67517d5..2b3f090c 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,9 @@ lint: $(foreach target,$(TARGETS),${target}/lint) "$(MAKE)" lint-rs "$(MAKE)" lint-go -test: +test: test-go test-rs + +test-go: go test \ -p 1 \ -v \ @@ -75,14 +77,20 @@ test: -out ${PWD}/junit.xml \ -set-exit-code +test-rs: + cargo llvm-cov \ + --workspace \ + --cobertura \ + --output-path "${PWD}/coverage-rs.xml" + test-integration: - "$(MAKE)" test GO_TEST_FLAGS=-tags=integration + "$(MAKE)" test-go GO_TEST_FLAGS=-tags=integration test-e2e: containers/e2e/local-build - "$(MAKE)" test GO_TEST_FLAGS=-tags=e2e - "$(MAKE)" test-e2e-convert + "$(MAKE)" test-go GO_TEST_FLAGS=-tags=e2e + "$(MAKE)" test-e2e-go-convert -test-e2e-convert: +test-e2e-go-convert: go tool covdata textfmt \ -i $(shell find ${PWD}/e2e/coverage/ -mindepth 1 -type d | xargs | sed 's/ /,/g') \ --pkg $(shell go list ./... | grep -v goauthentik.io/platform/vnd | grep -v goauthentik.io/platform/pkg/pb | xargs | sed 's/ /,/g') \ diff --git a/pam/src/auth.rs b/pam/src/auth.rs index b6d532fb..f4db60e0 100644 --- a/pam/src/auth.rs +++ b/pam/src/auth.rs @@ -26,6 +26,19 @@ pub mod token; pub const PW_PREFIX: &str = "\u{200b}"; pub const PW_PROMPT: &str = "authentik Password: "; +#[derive(Debug, PartialEq, Eq)] +enum AuthenticationAttempt { + Interactive, + Token(String), +} + +fn parse_authentication_attempt(password: &str) -> AuthenticationAttempt { + match password.strip_prefix(PW_PREFIX) { + Some(raw_token) => AuthenticationAttempt::Token(raw_token.to_owned()), + None => AuthenticationAttempt::Interactive, + } +} + pub fn authenticate_impl( pamh: &mut PamHandle, _args: Vec<&CStr>, @@ -94,29 +107,31 @@ pub fn authenticate_impl( } }; - if password.starts_with(PW_PREFIX) { - log::debug!("Token authentication"); - let raw_token = password.replace(PW_PREFIX, ""); - let decoded = match decode_pb::(raw_token) { - Ok(t) => t, - Err(e) => { - log::warn!("failed to decode token: {}", e); - return PamResultCode::PAM_ABORT; - } - }; - let token_res = match auth_token(username, decoded.token.to_owned(), bridge) { - Ok(t) => t, - Err(e) => return e, - }; - session_data.local_socket = decoded.local_socket; - session_id = token_res.session_id; - } else { - log::debug!("Interactive authentication"); - let int_res = match auth_interactive(username, password.to_owned(), &conv, bridge) { - Ok(ss) => ss, - Err(code) => return code, - }; - session_id = int_res.session_id; + match parse_authentication_attempt(password) { + AuthenticationAttempt::Token(raw_token) => { + log::debug!("Token authentication"); + let decoded = match decode_pb::(raw_token) { + Ok(t) => t, + Err(e) => { + log::warn!("failed to decode token: {}", e); + return PamResultCode::PAM_ABORT; + } + }; + let token_res = match auth_token(username, decoded.token.to_owned(), bridge) { + Ok(t) => t, + Err(e) => return e, + }; + session_data.local_socket = decoded.local_socket; + session_id = token_res.session_id; + } + AuthenticationAttempt::Interactive => { + log::debug!("Interactive authentication"); + let int_res = match auth_interactive(username, password.to_owned(), &conv, bridge) { + Ok(ss) => ss, + Err(code) => return code, + }; + session_id = int_res.session_id; + } } if !session_data.local_socket.is_empty() { pam_try_log!( @@ -138,3 +153,34 @@ pub fn authenticate_impl( ); PamResultCode::PAM_SUCCESS } + +#[cfg(test)] +mod tests { + use super::{AuthenticationAttempt, PW_PREFIX, parse_authentication_attempt}; + + #[test] + fn parses_interactive_passwords_without_prefix() { + assert_eq!( + parse_authentication_attempt("plain-password"), + AuthenticationAttempt::Interactive + ); + } + + #[test] + fn parses_prefixed_passwords_as_tokens() { + assert_eq!( + parse_authentication_attempt(&format!("{PW_PREFIX}encoded-token")), + AuthenticationAttempt::Token("encoded-token".to_owned()) + ); + } + + #[test] + fn only_strips_the_leading_prefix_marker() { + let password = format!("{PW_PREFIX}token{PW_PREFIX}body"); + + assert_eq!( + parse_authentication_attempt(&password), + AuthenticationAttempt::Token(format!("token{PW_PREFIX}body")) + ); + } +} diff --git a/pam/src/auth/interactive.rs b/pam/src/auth/interactive.rs index dfa9dc1c..fda1c4d6 100644 --- a/pam/src/auth/interactive.rs +++ b/pam/src/auth/interactive.rs @@ -176,3 +176,89 @@ pub fn auth_interactive( log::warn!("Exceeded maximum iterations"); Err(PamResultCode::PAM_ABORT) } + +#[cfg(test)] +mod tests { + use authentik_sys::generated::sys_auth::{ + InteractiveAuthResult, InteractiveChallenge, interactive_challenge::PromptMeta, + }; + use pam::constants::{ + PAM_BINARY_PROMPT, PAM_ERROR_MSG, PAM_PROMPT_ECHO_OFF, PAM_PROMPT_ECHO_ON, PAM_RADIO_TYPE, + PAM_TEXT_INFO, PamResultCode, + }; + + use super::{prompt_meta_to_pam_message_style, result_to_pam_result}; + + fn challenge(prompt_meta: PromptMeta) -> InteractiveChallenge { + InteractiveChallenge { + txid: String::new(), + finished: false, + result: 0, + prompt: String::new(), + prompt_meta: prompt_meta as i32, + debug_info: String::new(), + session_id: String::new(), + component: String::new(), + } + } + + #[test] + fn maps_interactive_results_to_pam_codes() { + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamSuccess as i32), + PamResultCode::PAM_SUCCESS + ); + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamPermDenied as i32), + PamResultCode::PAM_PERM_DENIED + ); + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamAuthErr as i32), + PamResultCode::PAM_AUTH_ERR + ); + } + + #[test] + fn falls_back_to_system_error_for_unknown_results() { + assert_eq!(result_to_pam_result(999), PamResultCode::PAM_SYSTEM_ERR); + } + + #[test] + fn maps_prompt_meta_values_to_pam_styles() { + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamBinaryPrompt)), + PAM_BINARY_PROMPT + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamErrorMsg)), + PAM_ERROR_MSG + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOff)), + PAM_PROMPT_ECHO_OFF + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOn)), + PAM_PROMPT_ECHO_ON + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamRadioType)), + PAM_RADIO_TYPE + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamTextInfo)), + PAM_TEXT_INFO + ); + } + + #[test] + fn defaults_to_echo_off_for_unknown_prompt_meta() { + let mut unknown = challenge(PromptMeta::Unspecified); + unknown.prompt_meta = 999; + + assert_eq!( + prompt_meta_to_pam_message_style(&unknown), + PAM_PROMPT_ECHO_OFF + ); + } +} diff --git a/pam/src/auth/token.rs b/pam/src/auth/token.rs index f3417ec0..81415a9d 100644 --- a/pam/src/auth/token.rs +++ b/pam/src/auth/token.rs @@ -3,6 +3,32 @@ use authentik_sys::generated::sys_auth::{TokenAuthRequest, TokenAuthResponse}; use authentik_sys::grpc::SysdBridge; use pam::constants::PamResultCode; +fn validate_token_auth_response( + username: &str, + response: &TokenAuthResponse, +) -> Result<(), PamResultCode> { + if !response.successful { + return Err(PamResultCode::PAM_AUTH_ERR); + } + + let token_username = response + .token + .as_ref() + .ok_or(PamResultCode::PAM_AUTH_ERR)? + .preferred_username + .as_str(); + if username != token_username { + log::warn!( + "User mismatch: token={:#?}, expected={:#?}", + token_username, + username + ); + return Err(PamResultCode::PAM_USER_UNKNOWN); + } + + Ok(()) +} + pub fn auth_token( username: String, token: String, @@ -23,23 +49,65 @@ pub fn auth_token( } }; - if !response.successful { - return Err(PamResultCode::PAM_AUTH_ERR); + validate_token_auth_response(&username, &response)?; + log::debug!("Got valid token: {response:#?}"); + Ok(response) +} + +#[cfg(test)] +mod tests { + use authentik_sys::generated::agent::Token; + use pam::constants::PamResultCode; + + use super::validate_token_auth_response; + use authentik_sys::generated::sys_auth::TokenAuthResponse; + + fn response(successful: bool, preferred_username: Option<&str>) -> TokenAuthResponse { + TokenAuthResponse { + successful, + token: preferred_username.map(|username| Token { + preferred_username: username.to_owned(), + iss: String::new(), + sub: String::new(), + aud: Vec::new(), + exp: None, + nbf: None, + iat: None, + jti: String::new(), + }), + session_id: "session-id".to_owned(), + } } - log::debug!("Got valid token: {response:#?}"); - let token_username = response - .token - .clone() - .ok_or(PamResultCode::PAM_AUTH_ERR)? - .preferred_username; - if username != token_username { - log::warn!( - "User mismatch: token={:#?}, expected={:#?}", - token_username, - username + #[test] + fn rejects_unsuccessful_responses() { + assert_eq!( + validate_token_auth_response("alice", &response(false, Some("alice"))), + Err(PamResultCode::PAM_AUTH_ERR) + ); + } + + #[test] + fn rejects_missing_token_payloads() { + assert_eq!( + validate_token_auth_response("alice", &response(true, None)), + Err(PamResultCode::PAM_AUTH_ERR) + ); + } + + #[test] + fn rejects_tokens_for_different_users() { + assert_eq!( + validate_token_auth_response("alice", &response(true, Some("bob"))), + Err(PamResultCode::PAM_USER_UNKNOWN) + ); + } + + #[test] + fn accepts_valid_token_responses() { + assert_eq!( + validate_token_auth_response("alice", &response(true, Some("alice"))), + Ok(()) ); - return Err(PamResultCode::PAM_USER_UNKNOWN); } - Ok(response) } diff --git a/pam/src/session_data.rs b/pam/src/session_data.rs index 6da64178..d7f5afd7 100644 --- a/pam/src/session_data.rs +++ b/pam/src/session_data.rs @@ -3,32 +3,61 @@ use pam::constants::PamResultCode; use serde::{Deserialize, Serialize}; use std::fs::{File, Permissions, remove_file}; -use std::io::Write; +use std::io::{Read, Write}; use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct SessionData { pub username: String, pub token: String, pub local_socket: String, } +pub fn session_file_path_for(base_dir: impl AsRef, id: &str) -> PathBuf { + base_dir.as_ref().join(format!(".aksm-{id}")) +} + pub fn _session_file(id: String) -> String { - format!("/tmp/.aksm-{id}") + session_file_path_for("/tmp", &id) + .to_string_lossy() + .into_owned() } -pub fn _read_session_data(id: String) -> Result { - let path = _session_file(id); - let file = match File::open(path) { +fn serialize_session_data(data: &SessionData) -> Result { + serde_json::to_string(data).map_err(|e| { + log::warn!("failed to json encode: {e}"); + PamResultCode::PAM_SESSION_ERR + }) +} + +fn read_session_data(reader: impl Read) -> Result { + serde_json::from_reader(reader).map_err(|e| { + log::warn!("failed to decode session data: {e}"); + PamResultCode::PAM_AUTH_ERR + }) +} + +fn write_session_data_file(path: &Path, data: &SessionData) -> Result<(), PamResultCode> { + let json_data = serialize_session_data(data)?; + let mut file = match File::create(path) { Ok(f) => f, Err(e) => { - log::warn!("failed to open file: {e}"); + log::warn!("failed to create file: {e}"); + return Err(PamResultCode::PAM_SESSION_ERR); + } + }; + + match file.set_permissions(Permissions::from_mode(0o400)) { + Ok(_) => {} + Err(e) => { + log::warn!("failed to get file permissions: {e}"); return Err(PamResultCode::PAM_SESSION_ERR); } }; - match serde_json::from_reader(file) { - Ok(t) => Ok(t), + match file.write_all(json_data.as_bytes()) { + Ok(_) => Ok(()), Err(e) => { log::warn!("failed to write session data: {e}"); Err(PamResultCode::PAM_AUTH_ERR) @@ -36,8 +65,21 @@ pub fn _read_session_data(id: String) -> Result { } } +pub fn _read_session_data(id: String) -> Result { + let path = session_file_path_for("/tmp", &id); + let file = match File::open(path) { + Ok(f) => f, + Err(e) => { + log::warn!("failed to open file: {e}"); + return Err(PamResultCode::PAM_SESSION_ERR); + } + }; + + read_session_data(file) +} + pub fn _delete_session_data(id: String) -> Result<(), PamResultCode> { - let path = _session_file(id); + let path = session_file_path_for("/tmp", &id); match remove_file(path) { Ok(_) => Ok(()), Err(e) => { @@ -48,35 +90,75 @@ pub fn _delete_session_data(id: String) -> Result<(), PamResultCode> { } pub fn _write_session_data(id: String, data: SessionData) -> Result<(), PamResultCode> { - let json_data = match serde_json::to_string(&data) { - Ok(j) => j, - Err(e) => { - log::warn!("failed to json encode: {e}"); - return Err(PamResultCode::PAM_SESSION_ERR); - } - }; - let path = _session_file(id); - let mut file = match File::create(path) { - Ok(f) => f, - Err(e) => { - log::warn!("failed to create file: {e}"); - return Err(PamResultCode::PAM_SESSION_ERR); - } + let path = session_file_path_for("/tmp", &id); + write_session_data_file(&path, &data) +} + +#[cfg(test)] +mod tests { + use super::{ + SessionData, read_session_data, serialize_session_data, session_file_path_for, + write_session_data_file, }; + use pam::constants::PamResultCode; + use std::fs; + use std::io::Cursor; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; - match file.set_permissions(Permissions::from_mode(0o400)) { - Ok(_) => {} - Err(e) => { - log::warn!("failed to get file permissions: {e}"); - return Err(PamResultCode::PAM_SESSION_ERR); + fn sample_session_data() -> SessionData { + SessionData { + username: "alice".to_owned(), + token: "token".to_owned(), + local_socket: "/tmp/socket".to_owned(), } - }; + } - match file.write_all(json_data.as_bytes()) { - Ok(_) => Ok(()), - Err(e) => { - log::warn!("failed to write session data: {e}"); + #[test] + fn builds_session_paths_in_the_requested_directory() { + assert_eq!( + session_file_path_for("/var/tmp", "abc123"), + PathBuf::from("/var/tmp/.aksm-abc123") + ); + } + + #[test] + fn round_trips_session_data_through_json() { + let data = sample_session_data(); + let encoded = serialize_session_data(&data).expect("should serialize"); + + let decoded = read_session_data(Cursor::new(encoded)).expect("should deserialize"); + + assert_eq!(decoded.username, data.username); + assert_eq!(decoded.token, data.token); + assert_eq!(decoded.local_socket, data.local_socket); + } + + #[test] + fn rejects_invalid_session_json() { + assert_eq!( + read_session_data(Cursor::new("{not-json")), Err(PamResultCode::PAM_AUTH_ERR) - } + ); + } + + #[test] + fn writes_session_data_with_read_only_permissions() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("authentik-pam-test-{unique}")); + fs::create_dir(&dir).expect("should create temp dir"); + let path = dir.join(".aksm-session"); + + write_session_data_file(&path, &sample_session_data()).expect("should write session file"); + + let metadata = fs::metadata(&path).expect("should stat session file"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o400); + + fs::remove_file(&path).expect("should clean up session file"); + fs::remove_dir(&dir).expect("should clean up temp dir"); } } From 50b282c7030e29951fb9b8608813be69c877d636 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 27 Mar 2026 23:04:52 +0100 Subject: [PATCH 2/5] fix install --- .github/workflows/test.yml | 6 +++++- Makefile | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efc17b2f..c0998290 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,7 +67,7 @@ jobs: with: args: --timeout 5000s test_rs: - name: Test (rs) + name: Test (rust) strategy: fail-fast: false matrix: @@ -81,6 +81,10 @@ jobs: - run: | sudo apt-get update sudo apt-get install -y libpam0g-dev libudev-dev + - name: Setup rust dependencies + uses: taiki-e/install-action@80a23c5ba9e1100fd8b777106e810018ed662a7b # v2 + with: + tool: cargo-llvm-cov nextest - run: make test-rs - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 if: ${{ always() }} diff --git a/Makefile b/Makefile index 2b3f090c..b2b23117 100644 --- a/Makefile +++ b/Makefile @@ -79,9 +79,14 @@ test-go: test-rs: cargo llvm-cov \ - --workspace \ + --no-report nextest \ + --workspace + cargo llvm-cov report \ --cobertura \ --output-path "${PWD}/coverage-rs.xml" + cargo llvm-cov report \ + --html \ + --output-dir "${PWD}/cache/utils_rs/coverage" test-integration: "$(MAKE)" test-go GO_TEST_FLAGS=-tags=integration From ca69d46f100a2c36b058fb78f17799d4ffb20bb0 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 27 Mar 2026 23:05:18 +0100 Subject: [PATCH 3/5] fix go --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0998290..3f84fe73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,7 +33,7 @@ jobs: cache: true - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - run: | - make test + make test-go - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 if: ${{ always() }} with: From 8287e16c65d46f14dfe6b7d5ed5c268d3111f988 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 27 Mar 2026 23:08:34 +0100 Subject: [PATCH 4/5] fix codecov --- .github/workflows/test.yml | 9 ++------- Makefile | 4 ++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3f84fe73..82cc3319 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,23 +86,18 @@ jobs: with: tool: cargo-llvm-cov nextest - run: make test-rs - - uses: test-summary/action@31493c76ec9e7aa675f1585d3ed6f1da69269a86 # v2 - if: ${{ always() }} - with: - paths: ${{ github.workspace }}/coverage-rs.xml - show: "fail" - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 if: ${{ always() }} with: use_oidc: true flags: go-${{ matrix.platform }} - files: ${{ github.workspace }}/coverage-rs.xml + files: ${{ github.workspace }}/coverage-rs.txt - uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v5 if: ${{ always() }} with: use_oidc: true flags: go-${{ matrix.platform }} - files: ${{ github.workspace }}/coverage-rs.xml + files: ${{ github.workspace }}/coverage-rs.txt report_type: test_results lint_rs: name: Lint (rust) diff --git a/Makefile b/Makefile index b2b23117..0648f8a8 100644 --- a/Makefile +++ b/Makefile @@ -82,8 +82,8 @@ test-rs: --no-report nextest \ --workspace cargo llvm-cov report \ - --cobertura \ - --output-path "${PWD}/coverage-rs.xml" + --codecov \ + --output-path "${PWD}/coverage-rs.txt" cargo llvm-cov report \ --html \ --output-dir "${PWD}/cache/utils_rs/coverage" From 397690149ed2d899b629b435aed3ce6c7a160a6e Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Fri, 27 Mar 2026 23:21:59 +0100 Subject: [PATCH 5/5] move separate tests --- pam/src/auth.rs | 34 ++---------- pam/src/auth/interactive.rs | 90 ++----------------------------- pam/src/auth/interactive_tests.rs | 82 ++++++++++++++++++++++++++++ pam/src/auth/tests.rs | 27 ++++++++++ pam/src/auth/token.rs | 62 ++------------------- pam/src/auth/token_tests.rs | 54 +++++++++++++++++++ pam/src/session_data.rs | 69 +----------------------- pam/src/session_data_tests.rs | 65 ++++++++++++++++++++++ 8 files changed, 241 insertions(+), 242 deletions(-) create mode 100644 pam/src/auth/interactive_tests.rs create mode 100644 pam/src/auth/tests.rs create mode 100644 pam/src/auth/token_tests.rs create mode 100644 pam/src/session_data_tests.rs diff --git a/pam/src/auth.rs b/pam/src/auth.rs index f4db60e0..570d5e6b 100644 --- a/pam/src/auth.rs +++ b/pam/src/auth.rs @@ -39,6 +39,9 @@ fn parse_authentication_attempt(password: &str) -> AuthenticationAttempt { } } +#[cfg(test)] +mod tests; + pub fn authenticate_impl( pamh: &mut PamHandle, _args: Vec<&CStr>, @@ -153,34 +156,3 @@ pub fn authenticate_impl( ); PamResultCode::PAM_SUCCESS } - -#[cfg(test)] -mod tests { - use super::{AuthenticationAttempt, PW_PREFIX, parse_authentication_attempt}; - - #[test] - fn parses_interactive_passwords_without_prefix() { - assert_eq!( - parse_authentication_attempt("plain-password"), - AuthenticationAttempt::Interactive - ); - } - - #[test] - fn parses_prefixed_passwords_as_tokens() { - assert_eq!( - parse_authentication_attempt(&format!("{PW_PREFIX}encoded-token")), - AuthenticationAttempt::Token("encoded-token".to_owned()) - ); - } - - #[test] - fn only_strips_the_leading_prefix_marker() { - let password = format!("{PW_PREFIX}token{PW_PREFIX}body"); - - assert_eq!( - parse_authentication_attempt(&password), - AuthenticationAttempt::Token(format!("token{PW_PREFIX}body")) - ); - } -} diff --git a/pam/src/auth/interactive.rs b/pam/src/auth/interactive.rs index fda1c4d6..5a19e95f 100644 --- a/pam/src/auth/interactive.rs +++ b/pam/src/auth/interactive.rs @@ -39,6 +39,10 @@ pub fn prompt_meta_to_pam_message_style(challenge: &InteractiveChallenge) -> Pam } } +#[cfg(test)] +#[path = "interactive_tests.rs"] +mod tests; + pub fn auth_interactive( username: String, password: String, @@ -176,89 +180,3 @@ pub fn auth_interactive( log::warn!("Exceeded maximum iterations"); Err(PamResultCode::PAM_ABORT) } - -#[cfg(test)] -mod tests { - use authentik_sys::generated::sys_auth::{ - InteractiveAuthResult, InteractiveChallenge, interactive_challenge::PromptMeta, - }; - use pam::constants::{ - PAM_BINARY_PROMPT, PAM_ERROR_MSG, PAM_PROMPT_ECHO_OFF, PAM_PROMPT_ECHO_ON, PAM_RADIO_TYPE, - PAM_TEXT_INFO, PamResultCode, - }; - - use super::{prompt_meta_to_pam_message_style, result_to_pam_result}; - - fn challenge(prompt_meta: PromptMeta) -> InteractiveChallenge { - InteractiveChallenge { - txid: String::new(), - finished: false, - result: 0, - prompt: String::new(), - prompt_meta: prompt_meta as i32, - debug_info: String::new(), - session_id: String::new(), - component: String::new(), - } - } - - #[test] - fn maps_interactive_results_to_pam_codes() { - assert_eq!( - result_to_pam_result(InteractiveAuthResult::PamSuccess as i32), - PamResultCode::PAM_SUCCESS - ); - assert_eq!( - result_to_pam_result(InteractiveAuthResult::PamPermDenied as i32), - PamResultCode::PAM_PERM_DENIED - ); - assert_eq!( - result_to_pam_result(InteractiveAuthResult::PamAuthErr as i32), - PamResultCode::PAM_AUTH_ERR - ); - } - - #[test] - fn falls_back_to_system_error_for_unknown_results() { - assert_eq!(result_to_pam_result(999), PamResultCode::PAM_SYSTEM_ERR); - } - - #[test] - fn maps_prompt_meta_values_to_pam_styles() { - assert_eq!( - prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamBinaryPrompt)), - PAM_BINARY_PROMPT - ); - assert_eq!( - prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamErrorMsg)), - PAM_ERROR_MSG - ); - assert_eq!( - prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOff)), - PAM_PROMPT_ECHO_OFF - ); - assert_eq!( - prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOn)), - PAM_PROMPT_ECHO_ON - ); - assert_eq!( - prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamRadioType)), - PAM_RADIO_TYPE - ); - assert_eq!( - prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamTextInfo)), - PAM_TEXT_INFO - ); - } - - #[test] - fn defaults_to_echo_off_for_unknown_prompt_meta() { - let mut unknown = challenge(PromptMeta::Unspecified); - unknown.prompt_meta = 999; - - assert_eq!( - prompt_meta_to_pam_message_style(&unknown), - PAM_PROMPT_ECHO_OFF - ); - } -} diff --git a/pam/src/auth/interactive_tests.rs b/pam/src/auth/interactive_tests.rs new file mode 100644 index 00000000..e7a42af0 --- /dev/null +++ b/pam/src/auth/interactive_tests.rs @@ -0,0 +1,82 @@ +use authentik_sys::generated::sys_auth::{ + InteractiveAuthResult, InteractiveChallenge, interactive_challenge::PromptMeta, +}; +use pam::constants::{ + PAM_BINARY_PROMPT, PAM_ERROR_MSG, PAM_PROMPT_ECHO_OFF, PAM_PROMPT_ECHO_ON, PAM_RADIO_TYPE, + PAM_TEXT_INFO, PamResultCode, +}; + +use super::{prompt_meta_to_pam_message_style, result_to_pam_result}; + +fn challenge(prompt_meta: PromptMeta) -> InteractiveChallenge { + InteractiveChallenge { + txid: String::new(), + finished: false, + result: 0, + prompt: String::new(), + prompt_meta: prompt_meta as i32, + debug_info: String::new(), + session_id: String::new(), + component: String::new(), + } +} + +#[test] +fn maps_interactive_results_to_pam_codes() { + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamSuccess as i32), + PamResultCode::PAM_SUCCESS + ); + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamPermDenied as i32), + PamResultCode::PAM_PERM_DENIED + ); + assert_eq!( + result_to_pam_result(InteractiveAuthResult::PamAuthErr as i32), + PamResultCode::PAM_AUTH_ERR + ); +} + +#[test] +fn falls_back_to_system_error_for_unknown_results() { + assert_eq!(result_to_pam_result(999), PamResultCode::PAM_SYSTEM_ERR); +} + +#[test] +fn maps_prompt_meta_values_to_pam_styles() { + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamBinaryPrompt)), + PAM_BINARY_PROMPT + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamErrorMsg)), + PAM_ERROR_MSG + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOff)), + PAM_PROMPT_ECHO_OFF + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamPromptEchoOn)), + PAM_PROMPT_ECHO_ON + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamRadioType)), + PAM_RADIO_TYPE + ); + assert_eq!( + prompt_meta_to_pam_message_style(&challenge(PromptMeta::PamTextInfo)), + PAM_TEXT_INFO + ); +} + +#[test] +fn defaults_to_echo_off_for_unknown_prompt_meta() { + let mut unknown = challenge(PromptMeta::Unspecified); + unknown.prompt_meta = 999; + + assert_eq!( + prompt_meta_to_pam_message_style(&unknown), + PAM_PROMPT_ECHO_OFF + ); +} diff --git a/pam/src/auth/tests.rs b/pam/src/auth/tests.rs new file mode 100644 index 00000000..dbce9111 --- /dev/null +++ b/pam/src/auth/tests.rs @@ -0,0 +1,27 @@ +use super::{AuthenticationAttempt, PW_PREFIX, parse_authentication_attempt}; + +#[test] +fn parses_interactive_passwords_without_prefix() { + assert_eq!( + parse_authentication_attempt("plain-password"), + AuthenticationAttempt::Interactive + ); +} + +#[test] +fn parses_prefixed_passwords_as_tokens() { + assert_eq!( + parse_authentication_attempt(&format!("{PW_PREFIX}encoded-token")), + AuthenticationAttempt::Token("encoded-token".to_owned()) + ); +} + +#[test] +fn only_strips_the_leading_prefix_marker() { + let password = format!("{PW_PREFIX}token{PW_PREFIX}body"); + + assert_eq!( + parse_authentication_attempt(&password), + AuthenticationAttempt::Token(format!("token{PW_PREFIX}body")) + ); +} diff --git a/pam/src/auth/token.rs b/pam/src/auth/token.rs index 81415a9d..0044d227 100644 --- a/pam/src/auth/token.rs +++ b/pam/src/auth/token.rs @@ -29,6 +29,10 @@ fn validate_token_auth_response( Ok(()) } +#[cfg(test)] +#[path = "token_tests.rs"] +mod tests; + pub fn auth_token( username: String, token: String, @@ -53,61 +57,3 @@ pub fn auth_token( log::debug!("Got valid token: {response:#?}"); Ok(response) } - -#[cfg(test)] -mod tests { - use authentik_sys::generated::agent::Token; - use pam::constants::PamResultCode; - - use super::validate_token_auth_response; - use authentik_sys::generated::sys_auth::TokenAuthResponse; - - fn response(successful: bool, preferred_username: Option<&str>) -> TokenAuthResponse { - TokenAuthResponse { - successful, - token: preferred_username.map(|username| Token { - preferred_username: username.to_owned(), - iss: String::new(), - sub: String::new(), - aud: Vec::new(), - exp: None, - nbf: None, - iat: None, - jti: String::new(), - }), - session_id: "session-id".to_owned(), - } - } - - #[test] - fn rejects_unsuccessful_responses() { - assert_eq!( - validate_token_auth_response("alice", &response(false, Some("alice"))), - Err(PamResultCode::PAM_AUTH_ERR) - ); - } - - #[test] - fn rejects_missing_token_payloads() { - assert_eq!( - validate_token_auth_response("alice", &response(true, None)), - Err(PamResultCode::PAM_AUTH_ERR) - ); - } - - #[test] - fn rejects_tokens_for_different_users() { - assert_eq!( - validate_token_auth_response("alice", &response(true, Some("bob"))), - Err(PamResultCode::PAM_USER_UNKNOWN) - ); - } - - #[test] - fn accepts_valid_token_responses() { - assert_eq!( - validate_token_auth_response("alice", &response(true, Some("alice"))), - Ok(()) - ); - } -} diff --git a/pam/src/auth/token_tests.rs b/pam/src/auth/token_tests.rs new file mode 100644 index 00000000..5eb5a641 --- /dev/null +++ b/pam/src/auth/token_tests.rs @@ -0,0 +1,54 @@ +use authentik_sys::generated::agent::Token; +use authentik_sys::generated::sys_auth::TokenAuthResponse; +use pam::constants::PamResultCode; + +use super::validate_token_auth_response; + +fn response(successful: bool, preferred_username: Option<&str>) -> TokenAuthResponse { + TokenAuthResponse { + successful, + token: preferred_username.map(|username| Token { + preferred_username: username.to_owned(), + iss: String::new(), + sub: String::new(), + aud: Vec::new(), + exp: None, + nbf: None, + iat: None, + jti: String::new(), + }), + session_id: "session-id".to_owned(), + } +} + +#[test] +fn rejects_unsuccessful_responses() { + assert_eq!( + validate_token_auth_response("alice", &response(false, Some("alice"))), + Err(PamResultCode::PAM_AUTH_ERR) + ); +} + +#[test] +fn rejects_missing_token_payloads() { + assert_eq!( + validate_token_auth_response("alice", &response(true, None)), + Err(PamResultCode::PAM_AUTH_ERR) + ); +} + +#[test] +fn rejects_tokens_for_different_users() { + assert_eq!( + validate_token_auth_response("alice", &response(true, Some("bob"))), + Err(PamResultCode::PAM_USER_UNKNOWN) + ); +} + +#[test] +fn accepts_valid_token_responses() { + assert_eq!( + validate_token_auth_response("alice", &response(true, Some("alice"))), + Ok(()) + ); +} diff --git a/pam/src/session_data.rs b/pam/src/session_data.rs index d7f5afd7..48c33ba0 100644 --- a/pam/src/session_data.rs +++ b/pam/src/session_data.rs @@ -95,70 +95,5 @@ pub fn _write_session_data(id: String, data: SessionData) -> Result<(), PamResul } #[cfg(test)] -mod tests { - use super::{ - SessionData, read_session_data, serialize_session_data, session_file_path_for, - write_session_data_file, - }; - use pam::constants::PamResultCode; - use std::fs; - use std::io::Cursor; - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn sample_session_data() -> SessionData { - SessionData { - username: "alice".to_owned(), - token: "token".to_owned(), - local_socket: "/tmp/socket".to_owned(), - } - } - - #[test] - fn builds_session_paths_in_the_requested_directory() { - assert_eq!( - session_file_path_for("/var/tmp", "abc123"), - PathBuf::from("/var/tmp/.aksm-abc123") - ); - } - - #[test] - fn round_trips_session_data_through_json() { - let data = sample_session_data(); - let encoded = serialize_session_data(&data).expect("should serialize"); - - let decoded = read_session_data(Cursor::new(encoded)).expect("should deserialize"); - - assert_eq!(decoded.username, data.username); - assert_eq!(decoded.token, data.token); - assert_eq!(decoded.local_socket, data.local_socket); - } - - #[test] - fn rejects_invalid_session_json() { - assert_eq!( - read_session_data(Cursor::new("{not-json")), - Err(PamResultCode::PAM_AUTH_ERR) - ); - } - - #[test] - fn writes_session_data_with_read_only_permissions() { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock should be after unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!("authentik-pam-test-{unique}")); - fs::create_dir(&dir).expect("should create temp dir"); - let path = dir.join(".aksm-session"); - - write_session_data_file(&path, &sample_session_data()).expect("should write session file"); - - let metadata = fs::metadata(&path).expect("should stat session file"); - assert_eq!(metadata.permissions().mode() & 0o777, 0o400); - - fs::remove_file(&path).expect("should clean up session file"); - fs::remove_dir(&dir).expect("should clean up temp dir"); - } -} +#[path = "session_data_tests.rs"] +mod tests; diff --git a/pam/src/session_data_tests.rs b/pam/src/session_data_tests.rs new file mode 100644 index 00000000..c27f9b6f --- /dev/null +++ b/pam/src/session_data_tests.rs @@ -0,0 +1,65 @@ +use super::{ + SessionData, read_session_data, serialize_session_data, session_file_path_for, + write_session_data_file, +}; +use pam::constants::PamResultCode; +use std::fs; +use std::io::Cursor; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn sample_session_data() -> SessionData { + SessionData { + username: "alice".to_owned(), + token: "token".to_owned(), + local_socket: "/tmp/socket".to_owned(), + } +} + +#[test] +fn builds_session_paths_in_the_requested_directory() { + assert_eq!( + session_file_path_for("/var/tmp", "abc123"), + PathBuf::from("/var/tmp/.aksm-abc123") + ); +} + +#[test] +fn round_trips_session_data_through_json() { + let data = sample_session_data(); + let encoded = serialize_session_data(&data).expect("should serialize"); + + let decoded = read_session_data(Cursor::new(encoded)).expect("should deserialize"); + + assert_eq!(decoded.username, data.username); + assert_eq!(decoded.token, data.token); + assert_eq!(decoded.local_socket, data.local_socket); +} + +#[test] +fn rejects_invalid_session_json() { + assert_eq!( + read_session_data(Cursor::new("{not-json")), + Err(PamResultCode::PAM_AUTH_ERR) + ); +} + +#[test] +fn writes_session_data_with_read_only_permissions() { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!("authentik-pam-test-{unique}")); + fs::create_dir(&dir).expect("should create temp dir"); + let path = dir.join(".aksm-session"); + + write_session_data_file(&path, &sample_session_data()).expect("should write session file"); + + let metadata = fs::metadata(&path).expect("should stat session file"); + assert_eq!(metadata.permissions().mode() & 0o777, 0o400); + + fs::remove_file(&path).expect("should clean up session file"); + fs::remove_dir(&dir).expect("should clean up temp dir"); +}