Skip to content

feat(server): add CredentialValidator trait for server-side auth#1172

Open
Greg Lamberson (glamberson) wants to merge 4 commits into
Devolutions:masterfrom
lamco-admin:feat/credential-validator
Open

feat(server): add CredentialValidator trait for server-side auth#1172
Greg Lamberson (glamberson) wants to merge 4 commits into
Devolutions:masterfrom
lamco-admin:feat/credential-validator

Conversation

@glamberson
Copy link
Copy Markdown
Contributor

@glamberson Greg Lamberson (glamberson) commented Mar 17, 2026

Closes #1154

Summary

Add a CredentialValidator trait that lets servers validate client
credentials during connection setup, after the acceptor extracts them
from ClientInfoPdu (enabled by #1155).

This follows up on the API discussion in #1150: Option<Credentials>
is the wrong type to carry security policy. The trait makes auth an
explicit, application-supplied component rather than an implicit
builder default.

What it does

  • CredentialValidator trait with validate(&self, &Credentials) -> Result<CredentialDecision, CredentialValidationError>.
  • CredentialDecision::{Accept, Reject} enum for the binary outcome (no stringly typed bool).
  • CredentialValidationError wrapping any core::error::Error + Send + Sync for backend-failure cases (LDAP/PAM/DB unreachable). Manual Display + core::error::Error impls; the source chain is preserved through .source().
  • RdpServerBuilder::with_credential_validator(Option<Arc<dyn CredentialValidator>>) to wire it in at construction time, matching the with_* pattern of every other configurable on BuilderDone.
  • RdpServer::set_credential_validator(Option<Arc<dyn CredentialValidator>>) for post-construction reconfiguration.
  • Validation runs in client_accepted() before session start.
  • No validator configured = no credential check (existing behavior).
  • Validator configured + credentials received = validate, reject on Decision::Reject or backend Err(_).
  • Validator configured + no credentials received = skip validation (covers CredSSP/Hybrid path and reactivation/resize iterations where AcceptorResult.credentials is None per fix(acceptor): skip credential check when server credentials are None #1150/feat(acceptor): expose received client credentials in AcceptorResult #1155).

Design

For TLS-only connections, Client Info credentials are the only
credentials the application layer receives (no CredSSP). This trait
gives server implementations a clean hook to validate them against
PAM, LDAP, databases, or any external system.

Not used for CredSSP/Hybrid connections, where authentication happens
during the NTLM challenge-response exchange before ClientInfoPdu.

Rejection is modelled as Ok(CredentialDecision::Reject), not as an
Err. A rejection from a working validator is the validator doing
its job, not a failure of the validator itself. Only true backend
infrastructure failures (the LDAP server is down, the PAM transport
broke) return Err(CredentialValidationError::new(_)). This keeps
the three outcomes (accept / reject / infrastructure-error) distinct
at every call site.

Hand-rolled Display + core::error::Error impls follow the
post-#1264 workspace pattern; no thiserror, no anyhow leakage
across the public trait boundary.

Example

use std::sync::Arc;
use ironrdp_server::{
    CredentialDecision, CredentialValidationError, CredentialValidator, Credentials,
    RdpServer,
};

struct PamValidator;

impl CredentialValidator for PamValidator {
    fn validate(
        &self,
        creds: &Credentials,
    ) -> Result<CredentialDecision, CredentialValidationError> {
        match pam_authenticate(&creds.username, &creds.password) {
            Ok(true) => Ok(CredentialDecision::Accept),
            Ok(false) => Ok(CredentialDecision::Reject),
            Err(backend_err) => Err(CredentialValidationError::new(backend_err)),
        }
    }
}

let server = RdpServer::builder()
    .with_addr(addr)
    .with_tls(acceptor)
    .with_input_handler(my_input)
    .with_display_handler(my_display)
    .with_credential_validator(Some(Arc::new(PamValidator)))
    .build();

Tests

Public-API tests for the trait, CredentialDecision, and the error
wrapper's source-chain propagation live in
crates/ironrdp-testsuite-core/tests/server/credential_validator.rs,
matching the canonical IronRDP split (public-API tests in
ironrdp-testsuite-core, inline #[cfg(test)] for crate-internal
behavior, per the existing autodetect.rs precedent).

Test plan

cargo xtask check fmt/lints/tests/typos/locks all pass.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a server-side hook to validate client-supplied credentials (from ClientInfoPdu) during connection setup, enabling external authentication backends (PAM/LDAP/DB) in ironrdp-server.

Changes:

  • Introduces a new public CredentialValidator trait.
  • Adds an optional validator to RdpServer with a setter API.
  • Invokes credential validation in client_accepted before entering the session loop.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

You can also share your feedback on Copilot code review. Take the survey.

Comment thread crates/ironrdp-server/src/server.rs
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/server.rs Outdated
Comment thread crates/ironrdp-server/src/server.rs
David T. Martel (David-Martel) added a commit to David-Martel/IronRDP that referenced this pull request Mar 27, 2026
- CredentialValidator trait in ironrdp-server for TLS-mode credential
  validation against external systems (PAM, LDAP, gateway RADIUS)
- CredentialProvider trait in ironrdp-acceptor for CredSSP/NLA runtime
  credential resolution (replaces static-only credentials)
- Server wires both into RdpServer with set_credential_validator() and
  set_credential_provider() methods
- Acceptor credential check now chains: provider -> static creds -> allow

Based on upstream PR Devolutions#1172 (glamberson) and formalco fork design.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@glamberson
Copy link
Copy Markdown
Contributor Author

Rebased. Thanks.

@glamberson
Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit) Hi, I hope you can consider this with some priority as it's a feature I require in both lamco-rdp-server and lamco-qemu-rdp (Proxmox RDP Server component) and currently resides in a fork which I'd like to eliminate.

Thanks!

Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request May 16, 2026
picky 7.0.0-rc.22 transitively required rand 0.10.0-rc.6 → rand_core
pre-release → getrandom =0.4.0-rc.0, which conflicted with downstream
crates depending on ashpd 0.13 (getrandom ^0.4 → 0.4.2 stable). Cargo's
pre-release semver in resolver 3 refuses to coexist these.

picky 7.0.0-rc.23 (published 2026-04-21) uses stable RustCrypto deps:
rand ^0.10 (stable), rand_core ^0.10 (stable), pulling getrandom ^0.4
which resolves cleanly to the same 0.4.x ashpd uses.

Local fork-only change to feat/lamco-combined; should be filed as a
separate upstream PR once Devolutions#1172 (CredentialValidator) lands and we drop
the fork.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request May 16, 2026
…root

The wildcard `pub use server::*` re-export in lib.rs was replaced with an
explicit list upstream (Devolutions#1270 wildcard cleanup, merged 2026-05-13). The
CredentialValidator commit predated that change, so the rebase onto current
master left the trait defined but not exported.

Adds CredentialValidator to the explicit re-export list so downstream
consumers (e.g., lamco-rdp-server/src/security/auth.rs) can resolve it
via `ironrdp_server::CredentialValidator`.

Folds into PR Devolutions#1172 when the fork retires.
@glamberson
Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit) Hi, could you please review this and merge it ASAP? This one is a real bottleneck for me. Do you have any issues or concerns you'd like me to address or any issues with merging otherwise? Please let me know if so. Thanks.

@glamberson Greg Lamberson (glamberson) force-pushed the feat/credential-validator branch 2 times, most recently from 1300186 to 02672ca Compare May 20, 2026 17:06
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request May 21, 2026
picky 7.0.0-rc.22 transitively required rand 0.10.0-rc.6 → rand_core
pre-release → getrandom =0.4.0-rc.0, which conflicted with downstream
crates depending on ashpd 0.13 (getrandom ^0.4 → 0.4.2 stable). Cargo's
pre-release semver in resolver 3 refuses to coexist these.

picky 7.0.0-rc.23 (published 2026-04-21) uses stable RustCrypto deps:
rand ^0.10 (stable), rand_core ^0.10 (stable), pulling getrandom ^0.4
which resolves cleanly to the same 0.4.x ashpd uses.

Local fork-only change to feat/lamco-combined; should be filed as a
separate upstream PR once Devolutions#1172 (CredentialValidator) lands and we drop
the fork.
Greg Lamberson (glamberson) added a commit to lamco-admin/IronRDP that referenced this pull request May 21, 2026
…root

The wildcard `pub use server::*` re-export in lib.rs was replaced with an
explicit list upstream (Devolutions#1270 wildcard cleanup, merged 2026-05-13). The
CredentialValidator commit predated that change, so the rebase onto current
master left the trait defined but not exported.

Adds CredentialValidator to the explicit re-export list so downstream
consumers (e.g., lamco-rdp-server/src/security/auth.rs) can resolve it
via `ironrdp_server::CredentialValidator`.

Folds into PR Devolutions#1172 when the fork retires.
Add a CredentialValidator trait that allows servers to validate
client credentials during connection setup. Called after the
acceptor phase extracts credentials from ClientInfoPdu.

This enables PAM, LDAP, and other validate-on-receipt auth flows
for TLS-mode connections. Not used for CredSSP/Hybrid connections
which handle authentication through NTLM challenge-response.

- Add CredentialValidator trait with validate() method
- Add set_credential_validator() to RdpServer
- Validate credentials in client_accepted() before session start
- Skip validation when credentials are None (reactivation, CredSSP)
  instead of bailing, consistent with Devolutions#1150/Devolutions#1155 design
- Remove username from log messages to avoid leaking sensitive data
- Keep validator error details in structured tracing field only
- Add spawn_blocking guidance to trait doc for blocking backends
Devolutions#1270 (wildcard cleanup) replaced `pub use server::*` with an explicit
re-export list. This PR predates that change, so without an update the
trait would be defined in server.rs but unreachable from downstream
consumers via `ironrdp_server::CredentialValidator`.

Adds CredentialValidator to the explicit list in lib.rs.
Reshape the public surface introduced earlier in this PR before it
lands, to avoid a future breaking touch when ironrdp-server's
anyhow-removal pass arrives. Mirrors the post-Devolutions#1264 hand-rolled
error pattern (Display + core::error::Error impls, no thiserror).

Trait signature was `Result<bool>` (i.e. anyhow::Result through the
file-level `use anyhow::Result`). Two issues:

- New public surface depending on anyhow at a moment when the
  workspace is moving the other way (Devolutions#1277 just removed anyhow from
  rdpsnd-native).
- `Ok(true)` vs `Ok(false)` is stringly typed at the call site: a
  bare bool with no semantic clue.

New shape:

- `CredentialDecision::{Accept, Reject}` for the binary outcome.
- `CredentialValidationError` wrapping any `core::error::Error +
  Send + Sync` for the case where the validator backend itself
  failed (LDAP unreachable, PAM transport error, etc.). Manual
  Display + Error impls; source chains through.
- `validate(&self, &Credentials) -> Result<CredentialDecision,
  CredentialValidationError>`.

The accept/reject/backend-error trichotomy is now explicit at every
call site. Rejection is no longer an error; backend failure is.
Log + bail strings updated to match the trichotomy.

Also addresses the API consistency note: every other configurable
on `RdpServerBuilder<BuilderDone>` goes through `with_*` on the
builder. Added `with_credential_validator(Option<Arc<dyn ...>>)`
following the same shape as `with_connection_handler`. The
post-construction `set_credential_validator` setter is kept (now
takes `Option` to match the field and the builder's setter shape)
for dynamic reconfiguration after `build()`.

Tests for the trait, the `CredentialDecision` enum, the error
wrapper's source chaining, and the `Send + Sync + 'static` bounds
through `Arc<dyn _>` live in
`crates/ironrdp-testsuite-core/tests/server/credential_validator.rs`,
matching the canonical IronRDP split: public-API tests in
`ironrdp-testsuite-core`, inline `#[cfg(test)]` only for
crate-internal behavior (per the `autodetect.rs` precedent which
has both: inline tests on internal state machine, public-API tests
in `testsuite-core/tests/server/autodetect.rs`).

Verification: `cargo xtask check fmt/lints/tests/typos/locks` all
pass; new tests pass in the testsuite-core harness (4 added).

Refs Devolutions#1154, Devolutions#1150, Devolutions#1155.
@glamberson
Copy link
Copy Markdown
Contributor Author

Benoît Cortier (@CBenoit) Today is 2026-05-25. PR #1172 has been open since 2026-03-17, which is 70 days. CI is green across all 20 checks on the current head (1f8d5014, just rebased onto master 879ffed8). All 5 Copilot review threads from the 2026-03-18 pass are resolved. There has never been a maintainer review on this PR; the only review history is Copilot's. I have rebased four times and pushed two substantive reshapes, including the 2026-05-20 typed-error refactor that moved the API from anyhow-leaking Result<bool> to CredentialDecision::{Accept, Reject} plus a CredentialValidationError wrapper with manual Display + core::error::Error impls, and the migration of public-API tests into ironrdp-testsuite-core/tests/server/credential_validator.rs per the autodetect.rs precedent.

I have pinged twice (2026-05-16, 2026-05-20). Both pings explicitly invited "what do you want me to change" feedback. Neither got a response. In the same five-day window since the 2026-05-20 ping, you have merged eight of my other PRs, including a Saturday-filed one (#1303) that landed Monday morning. The signal is that this specific PR is being deferred, not lost in queue.

I respect that Devolutions has priorities I do not see and competing directions I am not party to. Those are legitimate. I have tried to bend over backwards to align my work with what I understand about your priorities: the precheck-driven amend cadence, the testsuite-core test-placement convention, the typed-error-no-anyhow direction, the conventional-commit + BREAKING CHANGE: discipline for release-plz, the with_* builder convention, the spec-MUST-on-emit + liberal-on-receive posture you've prescribed elsewhere. I would appreciate some attention to mine here. lamco-rdp-server and the planned lamco-qemu-rdp (Proxmox RDP server component) both have a hard dependency on a server-side credential-validation hook. I have been carrying that hook on feat/lamco-combined in our fork for 70 days. Every release I cut from that branch is a fork-rebase exercise I would prefer not to do indefinitely.

What I want is engagement, not approval. The options I see, from my side:

  1. The trait is fine but you want a different shape. Tell me which shape (different signature, different placement on ConnectionHandler instead of standalone, different name, dropped builder method, different lifecycle hook, anything) and I will rework. I have explicit cycles available this week.
  2. You do not want this surface upstream at all (Devolutions has plans for credential validation that conflict, the trait surface is wrong for IronRDP's API direction, or any other reason). Tell me and I will close the PR with a documenting comment and carry the patch downstream permanently. No friction.
  3. You want the discussion but the shape needs broader maintainer input first. Tell me and I will wait, with a known target date or a known trigger event.

Competing priorities, conflicting thoughts, structural concerns, scope disagreements: all of these are fine and I welcome the discussion. Silence is what is not working here. What is the path forward?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Support server-side credential validation (e.g. PAM) via CredentialValidator trait

2 participants