From 4df621c5c8b5d369103c58b9e82d325fd63e3111 Mon Sep 17 00:00:00 2001 From: David Culbreth Date: Fri, 20 Mar 2026 12:37:24 -0500 Subject: [PATCH] adding some initial SSO providers, updating publish workflow --- .gitea/workflows/publish.yml | 48 +- AGENTS.md | 4 + Cargo.lock | 34 ++ config.development.yaml | 6 + config.example.yaml | 21 + crates/api/Cargo.toml | 1 + crates/api/src/auth/ldap.rs | 479 ++++++++++++++++++ crates/api/src/auth/mod.rs | 1 + crates/api/src/dto/auth.rs | 20 + crates/api/src/openapi.rs | 41 ++ crates/api/src/routes/auth.rs | 63 ++- crates/api/tests/health_and_auth_tests.rs | 120 +++++ crates/common/src/config.rs | 198 ++++++++ crates/common/src/repositories/identity.rs | 21 + .../common/tests/identity_repository_tests.rs | 170 +++++++ docs/deployment/gitea-registry-and-helm.md | 11 +- web/src/pages/auth/LoginPage.tsx | 167 +++++- .../2026-03-19-ldap-authentication.md | 63 +++ 18 files changed, 1456 insertions(+), 12 deletions(-) create mode 100644 crates/api/src/auth/ldap.rs create mode 100644 work-summary/2026-03-19-ldap-authentication.md diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index dbcc9d0..95b0eeb 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -10,8 +10,9 @@ on: - "v*" env: - REGISTRY_HOST: ${{ vars.CONTAINER_REGISTRY_HOST }} + REGISTRY_HOST: ${{ vars.CLUSTER_GITEA_HOST }} REGISTRY_NAMESPACE: ${{ vars.CONTAINER_REGISTRY_NAMESPACE }} + REGISTRY_PLAIN_HTTP: ${{ vars.CONTAINER_REGISTRY_INSECURE }} CHART_NAME: attune jobs: @@ -21,6 +22,7 @@ jobs: outputs: registry: ${{ steps.meta.outputs.registry }} namespace: ${{ steps.meta.outputs.namespace }} + registry_plain_http: ${{ steps.meta.outputs.registry_plain_http }} image_tag: ${{ steps.meta.outputs.image_tag }} image_tags: ${{ steps.meta.outputs.image_tags }} chart_version: ${{ steps.meta.outputs.chart_version }} @@ -35,9 +37,10 @@ jobs: registry="${REGISTRY_HOST}" namespace="${REGISTRY_NAMESPACE}" + registry_plain_http_raw="${REGISTRY_PLAIN_HTTP:-}" if [ -z "$registry" ]; then - echo "CONTAINER_REGISTRY_HOST repository variable is required" + echo "CLUSTER_GITEA_HOST app variable is required" exit 1 fi @@ -45,6 +48,15 @@ jobs: namespace="${{ github.repository_owner }}" fi + case "$(printf '%s' "$registry_plain_http_raw" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|on) + registry_plain_http="true" + ;; + *) + registry_plain_http="false" + ;; + esac + short_sha="$(printf '%s' "${{ github.sha }}" | cut -c1-12)" ref_type="${{ github.ref_type }}" ref_name="${{ github.ref_name }}" @@ -64,6 +76,7 @@ jobs: { echo "registry=$registry" echo "namespace=$namespace" + echo "registry_plain_http=$registry_plain_http" echo "image_tag=$version" echo "image_tags=$image_tags" echo "chart_version=$chart_version" @@ -141,8 +154,18 @@ jobs: uses: actions/checkout@v4 - name: Setup Docker Buildx + if: needs.metadata.outputs.registry_plain_http != 'true' uses: docker/setup-buildx-action@v3 + - name: Setup Docker Buildx For Plain HTTP Registry + if: needs.metadata.outputs.registry_plain_http == 'true' + uses: docker/setup-buildx-action@v3 + with: + buildkitd-config-inline: | + [registry."${{ needs.metadata.outputs.registry }}"] + http = true + insecure = true + - name: Log in to Gitea OCI registry shell: bash env: @@ -153,13 +176,18 @@ jobs: set -euo pipefail username="${REGISTRY_USERNAME:-${{ github.actor }}}" password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}" + registry="${{ needs.metadata.outputs.registry }}" if [ -z "$password" ]; then echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes" exit 1 fi - printf '%s' "$password" | docker login "${{ needs.metadata.outputs.registry }}" \ + if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then + registry="http://${registry}" + fi + + printf '%s' "$password" | docker login "$registry" \ --username "$username" \ --password-stdin @@ -224,14 +252,20 @@ jobs: set -euo pipefail registry_username="${REGISTRY_USERNAME:-${{ github.actor }}}" registry_password="${REGISTRY_PASSWORD:-${GITHUB_TOKEN_FALLBACK:-}}" + login_args=() if [ -z "$registry_password" ]; then echo "Set CONTAINER_REGISTRY_PASSWORD or enable GITHUB_TOKEN package writes" exit 1 fi + if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then + login_args+=(--plain-http) + fi + printf '%s' "$registry_password" | helm registry login "${{ needs.metadata.outputs.registry }}" \ --username "$registry_username" \ + "${login_args[@]}" \ --password-stdin - name: Lint chart @@ -248,5 +282,11 @@ jobs: - name: Push chart to OCI registry run: | + push_args=() + if [ "${{ needs.metadata.outputs.registry_plain_http }}" = "true" ]; then + push_args+=(--plain-http) + fi + helm push "dist/${CHART_NAME}-${{ needs.metadata.outputs.chart_version }}.tgz" \ - "oci://${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/helm" + "oci://${{ needs.metadata.outputs.registry }}/${{ needs.metadata.outputs.namespace }}/helm" \ + "${push_args[@]}" diff --git a/AGENTS.md b/AGENTS.md index 4dfa025..17549bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,6 +208,10 @@ Completion listener advances workflow → Schedules successor tasks → Complete - **Auth Type**: JWT (access tokens: 1h, refresh tokens: 7d) - **Password Hashing**: Argon2id - **Protected Routes**: Use `RequireAuth(user)` extractor in Axum +- **External Identity Providers**: OIDC and LDAP are supported as optional login methods alongside local username/password. Both upsert an `identity` row on first login and store provider-specific claims under `attributes.oidc` or `attributes.ldap` respectively. The web UI login page adapts dynamically based on the `GET /auth/settings` response, showing/hiding each method. The `?auth=` query parameter overrides which method is displayed (e.g., `?auth=direct`, `?auth=sso`, `?auth=ldap`). + - **OIDC** (`crates/api/src/auth/oidc.rs`): Browser-redirect flow using the `openidconnect` crate. Config: `security.oidc` in YAML. Routes: `GET /auth/oidc/login` (redirect to provider), `GET /auth/callback` (authorization code exchange). Identity matched by `attributes->'oidc'->>'issuer'` + `attributes->'oidc'->>'sub'`. Supports PKCE, ID token verification via JWKS, userinfo endpoint enrichment, and provider-initiated logout via `end_session_endpoint`. + - **LDAP** (`crates/api/src/auth/ldap.rs`): Server-side bind flow using the `ldap3` crate. Config: `security.ldap` in YAML. Route: `POST /auth/ldap/login` (accepts `{login, password}`, returns `TokenResponse`). Two authentication modes: **direct bind** (construct DN from `bind_dn_template` with `{login}` placeholder) or **search-and-bind** (bind as service account → search `user_search_base` with `user_filter` → re-bind as discovered DN). Identity matched by `attributes->'ldap'->>'server_url'` + `attributes->'ldap'->>'dn'`. Supports STARTTLS, TLS cert skip (`danger_skip_tls_verify`), and configurable attribute mapping (`login_attr`, `email_attr`, `display_name_attr`, `group_attr`). + - **Login Page Config** (`security.login_page`): `show_local_login`, `show_oidc_login`, `show_ldap_login` — all default to `true`. Controls which methods are visible by default on the web UI login page. - **Secrets Storage**: AES-GCM encrypted in `key` table (JSONB `value` column) with scoped ownership. Supports structured values (objects, arrays) in addition to plain strings. All encryption/decryption goes through `attune_common::crypto` (`encrypt_json`/`decrypt_json`) — the worker's `SecretManager` no longer has its own crypto implementation, eliminating a prior ciphertext format incompatibility between the API (`BASE64(nonce++ciphertext)`) and the old worker code (`BASE64(nonce):BASE64(ciphertext)`). The worker stores the raw encryption key string and passes it to the shared crypto module, which derives the AES-256 key internally via SHA-256. - **User Info**: Stored in `identity` table diff --git a/Cargo.lock b/Cargo.lock index 428fd85..281a91b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -477,6 +477,7 @@ dependencies = [ "hmac", "jsonschema", "jsonwebtoken", + "ldap3", "mockall", "openidconnect", "rand 0.10.0", @@ -3043,6 +3044,39 @@ dependencies = [ "spin", ] +[[package]] +name = "lber" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcf559624bfd9fe8d488329a8959766335a43a9b8b2cdd6a2c379fca02909a5" +dependencies = [ + "bytes", + "nom 7.1.3", +] + +[[package]] +name = "ldap3" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fe89f5e7cfb7e4701e3a38ff9f00358e026a9aee940355d88ee9d81e5c7503" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lber", + "log", + "native-tls", + "nom 7.1.3", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + [[package]] name = "leb128fmt" version = "0.1.0" diff --git a/config.development.yaml b/config.development.yaml index 5d6a059..b51c20c 100644 --- a/config.development.yaml +++ b/config.development.yaml @@ -56,6 +56,12 @@ security: post_logout_redirect_uri: http://localhost:3000/login scopes: - groups + ldap: + enabled: false + url: ldap://localhost:389 + bind_dn_template: "uid={login},ou=users,dc=example,dc=com" + provider_name: ldap + provider_label: Development LDAP # Packs directory (where pack action files are located) packs_base_dir: ./packs diff --git a/config.example.yaml b/config.example.yaml index a5bbcd4..9e20e5c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -92,6 +92,7 @@ security: login_page: show_local_login: true show_oidc_login: true + show_ldap_login: true # Optional OIDC browser login configuration oidc: @@ -107,6 +108,26 @@ security: scopes: - groups + # Optional LDAP authentication configuration + ldap: + enabled: false + url: ldap://ldap.example.com:389 + # Direct-bind mode: construct DN from template + # bind_dn_template: "uid={login},ou=users,dc=example,dc=com" + # Search-and-bind mode: search for user with a service account + user_search_base: "ou=users,dc=example,dc=com" + user_filter: "(uid={login})" + search_bind_dn: "cn=readonly,dc=example,dc=com" + search_bind_password: "readonly-password" + login_attr: uid + email_attr: mail + display_name_attr: cn + group_attr: memberOf + starttls: false + danger_skip_tls_verify: false + provider_name: ldap + provider_label: Company LDAP + # Worker configuration (optional, for worker services) # Uncomment and configure if running worker processes # worker: diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index bc7b930..30081e5 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -70,6 +70,7 @@ jsonschema = { workspace = true } # HTTP client reqwest = { workspace = true } openidconnect = "4.0" +ldap3 = "0.12" url = { workspace = true } # Archive/compression diff --git a/crates/api/src/auth/ldap.rs b/crates/api/src/auth/ldap.rs new file mode 100644 index 0000000..612b89f --- /dev/null +++ b/crates/api/src/auth/ldap.rs @@ -0,0 +1,479 @@ +//! LDAP authentication helpers for username/password login. + +use attune_common::{ + config::LdapConfig, + repositories::{ + identity::{CreateIdentityInput, IdentityRepository, UpdateIdentityInput}, + Create, Update, + }, +}; +use ldap3::{dn_escape, ldap_escape, Ldap, LdapConnAsync, LdapConnSettings, Scope, SearchEntry}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::{ + auth::jwt::{generate_access_token, generate_refresh_token}, + dto::TokenResponse, + middleware::error::ApiError, + state::SharedState, +}; + +/// Claims extracted from the LDAP directory for an authenticated user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LdapUserClaims { + /// The LDAP server URL the user was authenticated against. + pub server_url: String, + /// The user's full distinguished name. + pub dn: String, + /// Login attribute value (uid, sAMAccountName, etc.). + pub login: Option, + /// Email address. + pub email: Option, + /// Display name (cn). + pub display_name: Option, + /// Group memberships (memberOf values). + pub groups: Vec, +} + +/// The result of a successful LDAP authentication. +#[derive(Debug, Clone)] +pub struct LdapAuthenticatedIdentity { + pub token_response: TokenResponse, +} + +/// Authenticate a user against the configured LDAP directory. +/// +/// This performs a bind (either direct or search+bind) to verify +/// the user's credentials, then fetches their attributes and upserts +/// the identity in the database. +pub async fn authenticate( + state: &SharedState, + login: &str, + password: &str, +) -> Result { + let ldap_config = ldap_config(state)?; + + // Connect and authenticate + let claims = if ldap_config.bind_dn_template.is_some() { + direct_bind(&ldap_config, login, password).await? + } else { + search_and_bind(&ldap_config, login, password).await? + }; + + // Upsert identity in DB and issue JWT tokens + let identity = upsert_identity(state, &claims).await?; + let access_token = generate_access_token(identity.id, &identity.login, &state.jwt_config)?; + let refresh_token = generate_refresh_token(identity.id, &identity.login, &state.jwt_config)?; + + let token_response = TokenResponse::new( + access_token, + refresh_token, + state.jwt_config.access_token_expiration, + ) + .with_user( + identity.id, + identity.login.clone(), + identity.display_name.clone(), + ); + + Ok(LdapAuthenticatedIdentity { token_response }) +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +fn ldap_config(state: &SharedState) -> Result { + let config = state + .config + .security + .ldap + .clone() + .filter(|ldap| ldap.enabled) + .ok_or_else(|| { + ApiError::NotImplemented("LDAP authentication is not configured".to_string()) + })?; + + // Reject partial service-account configuration: having exactly one of + // search_bind_dn / search_bind_password is almost certainly a config + // error and would silently fall back to anonymous search, which is a + // very different security posture than the admin intended. + let has_dn = config.search_bind_dn.is_some(); + let has_pw = config.search_bind_password.is_some(); + if has_dn != has_pw { + let missing = if has_dn { + "search_bind_password" + } else { + "search_bind_dn" + }; + return Err(ApiError::InternalServerError(format!( + "LDAP misconfiguration: search_bind_dn and search_bind_password must both be set \ + or both be omitted (missing {missing})" + ))); + } + + Ok(config) +} + +/// Build an `LdapConnSettings` from the config. +fn conn_settings(config: &LdapConfig) -> LdapConnSettings { + let mut settings = LdapConnSettings::new(); + if config.starttls { + settings = settings.set_starttls(true); + } + if config.danger_skip_tls_verify { + settings = settings.set_no_tls_verify(true); + } + settings +} + +/// Open a new LDAP connection. +async fn connect(config: &LdapConfig) -> Result { + let settings = conn_settings(config); + let (conn, ldap) = LdapConnAsync::with_settings(settings, &config.url) + .await + .map_err(|err| { + ApiError::InternalServerError(format!("Failed to connect to LDAP server: {err}")) + })?; + // Drive the connection in the background + ldap3::drive!(conn); + Ok(ldap) +} + +/// Direct-bind authentication: construct the DN from the template and bind. +async fn direct_bind( + config: &LdapConfig, + login: &str, + password: &str, +) -> Result { + let template = config.bind_dn_template.as_deref().unwrap_or_default(); + // Escape the login value for safe interpolation into a Distinguished Name + // (RFC 4514). Without this, characters like `,`, `+`, `"`, `\`, `<`, `>`, + // `;`, `=`, NUL, `#` (leading), or space (leading/trailing) in the username + // would alter the DN structure. + let escaped_login = dn_escape(login); + let bind_dn = template.replace("{login}", &escaped_login); + + let mut ldap = connect(config).await?; + + // Bind as the user + let result = ldap + .simple_bind(&bind_dn, password) + .await + .map_err(|err| ApiError::InternalServerError(format!("LDAP bind failed: {err}")))?; + + if result.rc != 0 { + let _ = ldap.unbind().await; + return Err(ApiError::Unauthorized( + "Invalid LDAP credentials".to_string(), + )); + } + + // Fetch user attributes + let claims = fetch_user_attributes(config, &mut ldap, &bind_dn).await?; + + let _ = ldap.unbind().await; + Ok(claims) +} + +/// Search-and-bind authentication: +/// 1. Bind as the service account (or anonymous) +/// 2. Search for the user entry (must match exactly one) +/// 3. Re-bind as the user with their DN + password +async fn search_and_bind( + config: &LdapConfig, + login: &str, + password: &str, +) -> Result { + let search_base = config.user_search_base.as_deref().ok_or_else(|| { + ApiError::InternalServerError( + "LDAP user_search_base is required when bind_dn_template is not set".to_string(), + ) + })?; + + let mut ldap = connect(config).await?; + + // Step 1: Bind as service account or anonymous. + // Partial config (only one of dn/password) is already rejected by + // ldap_config(), so this match is exhaustive over valid states. + if let (Some(bind_dn), Some(bind_pw)) = ( + config.search_bind_dn.as_deref(), + config.search_bind_password.as_deref(), + ) { + let result = ldap.simple_bind(bind_dn, bind_pw).await.map_err(|err| { + ApiError::InternalServerError(format!("LDAP service bind failed: {err}")) + })?; + if result.rc != 0 { + let _ = ldap.unbind().await; + return Err(ApiError::InternalServerError( + "LDAP service account bind failed — check search_bind_dn and search_bind_password" + .to_string(), + )); + } + } + // If no service account, we proceed with an anonymous connection (already connected) + + // Step 2: Search for the user. + // Escape the login value for safe interpolation into an LDAP search filter + // (RFC 4515). Without this, characters like `(`, `)`, `*`, `\`, and NUL in + // the username could broaden the filter, match unintended entries, or break + // the search entirely. + let escaped_login = ldap_escape(login); + let filter = config.user_filter.replace("{login}", &escaped_login); + let attrs = vec![ + config.login_attr.as_str(), + config.email_attr.as_str(), + config.display_name_attr.as_str(), + config.group_attr.as_str(), + "dn", + ]; + + let (results, _result) = ldap + .search(search_base, Scope::Subtree, &filter, attrs) + .await + .map_err(|err| ApiError::InternalServerError(format!("LDAP user search failed: {err}")))? + .success() + .map_err(|err| ApiError::InternalServerError(format!("LDAP search error: {err}")))?; + + // The search must return exactly one entry. Zero means the user was not + // found; more than one means the filter or directory layout is ambiguous + // and we must not guess which identity to authenticate. + let result_count = results.len(); + if result_count == 0 { + let _ = ldap.unbind().await; + return Err(ApiError::Unauthorized( + "Invalid LDAP credentials".to_string(), + )); + } + if result_count > 1 { + let _ = ldap.unbind().await; + return Err(ApiError::InternalServerError(format!( + "LDAP user search returned {result_count} entries (expected exactly 1) — \ + tighten the user_filter or user_search_base to ensure uniqueness" + ))); + } + + // SAFETY: result_count == 1 guaranteed by the checks above. + let entry = results + .into_iter() + .next() + .expect("checked result_count == 1"); + let search_entry = SearchEntry::construct(entry); + let user_dn = search_entry.dn.clone(); + + // Step 3: Re-bind as the user + let result = ldap + .simple_bind(&user_dn, password) + .await + .map_err(|err| ApiError::InternalServerError(format!("LDAP user bind failed: {err}")))?; + if result.rc != 0 { + let _ = ldap.unbind().await; + return Err(ApiError::Unauthorized( + "Invalid LDAP credentials".to_string(), + )); + } + + let claims = extract_claims(config, &search_entry); + let _ = ldap.unbind().await; + Ok(claims) +} + +/// Fetch the user's LDAP attributes after a successful bind. +async fn fetch_user_attributes( + config: &LdapConfig, + ldap: &mut Ldap, + user_dn: &str, +) -> Result { + let attrs = vec![ + config.login_attr.as_str(), + config.email_attr.as_str(), + config.display_name_attr.as_str(), + config.group_attr.as_str(), + ]; + + let (results, _result) = ldap + .search(user_dn, Scope::Base, "(objectClass=*)", attrs) + .await + .map_err(|err| { + ApiError::InternalServerError(format!( + "LDAP attribute fetch failed for DN {user_dn}: {err}" + )) + })? + .success() + .map_err(|err| { + ApiError::InternalServerError(format!("LDAP attribute search error: {err}")) + })?; + + let entry = results.into_iter().next().ok_or_else(|| { + ApiError::InternalServerError(format!("LDAP entry not found for DN: {user_dn}")) + })?; + let search_entry = SearchEntry::construct(entry); + + Ok(extract_claims(config, &search_entry)) +} + +/// Extract user claims from an LDAP search entry. +fn extract_claims(config: &LdapConfig, entry: &SearchEntry) -> LdapUserClaims { + let first_attr = + |name: &str| -> Option { entry.attrs.get(name).and_then(|v| v.first()).cloned() }; + + let groups = entry + .attrs + .get(&config.group_attr) + .cloned() + .unwrap_or_default(); + + LdapUserClaims { + server_url: config.url.clone(), + dn: entry.dn.clone(), + login: first_attr(&config.login_attr), + email: first_attr(&config.email_attr), + display_name: first_attr(&config.display_name_attr), + groups, + } +} + +/// Upsert an identity row for the LDAP-authenticated user. +async fn upsert_identity( + state: &SharedState, + claims: &LdapUserClaims, +) -> Result { + let existing = + IdentityRepository::find_by_ldap_dn(&state.db, &claims.server_url, &claims.dn).await?; + let desired_login = derive_login(claims); + let display_name = claims.display_name.clone(); + let attributes = json!({ "ldap": claims }); + + match existing { + Some(identity) => { + let updated = UpdateIdentityInput { + display_name, + password_hash: None, + attributes: Some(attributes), + }; + IdentityRepository::update(&state.db, identity.id, updated) + .await + .map_err(Into::into) + } + None => { + // Avoid login collisions + let login = match IdentityRepository::find_by_login(&state.db, &desired_login).await? { + Some(_) => fallback_dn_login(claims), + None => desired_login, + }; + + IdentityRepository::create( + &state.db, + CreateIdentityInput { + login, + display_name, + password_hash: None, + attributes, + }, + ) + .await + .map_err(Into::into) + } + } +} + +/// Derive the login name from LDAP claims. +fn derive_login(claims: &LdapUserClaims) -> String { + claims + .login + .clone() + .or_else(|| claims.email.clone()) + .unwrap_or_else(|| fallback_dn_login(claims)) +} + +/// Generate a deterministic fallback login from the LDAP server URL + DN. +fn fallback_dn_login(claims: &LdapUserClaims) -> String { + let mut hasher = Sha256::new(); + hasher.update(claims.server_url.as_bytes()); + hasher.update(b":"); + hasher.update(claims.dn.as_bytes()); + let digest = hex::encode(hasher.finalize()); + format!("ldap:{}", &digest[..24]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_bind_dn_escapes_special_characters() { + // Simulate what direct_bind does with the template + let template = "uid={login},ou=users,dc=example,dc=com"; + let malicious_login = "admin,ou=admins,dc=evil,dc=com"; + let escaped = dn_escape(malicious_login); + let bind_dn = template.replace("{login}", &escaped); + // The commas in the login value must be escaped so they don't + // introduce additional RDN components. + assert!( + bind_dn.contains("\\2c"), + "commas in login must be escaped in DN: {bind_dn}" + ); + assert!( + bind_dn.starts_with("uid=admin\\2cou\\3dadmins\\2cdc\\3devil\\2cdc\\3dcom,ou=users"), + "DN structure must be preserved: {bind_dn}" + ); + } + + #[test] + fn search_filter_escapes_special_characters() { + let filter_template = "(uid={login})"; + let malicious_login = "admin)(|(uid=*))"; + let escaped = ldap_escape(malicious_login); + let filter = filter_template.replace("{login}", &escaped); + // The parentheses and asterisk must be escaped so they don't + // alter the filter structure. + assert!( + !filter.contains(")("), + "parentheses in login must be escaped in filter: {filter}" + ); + assert!( + filter.contains("\\28"), + "open-paren must be hex-escaped: {filter}" + ); + assert!( + filter.contains("\\29"), + "close-paren must be hex-escaped: {filter}" + ); + assert!( + filter.contains("\\2a"), + "asterisk must be hex-escaped: {filter}" + ); + } + + #[test] + fn dn_escape_preserves_safe_usernames() { + let safe = "jdoe"; + let escaped = dn_escape(safe); + assert_eq!(escaped.as_ref(), "jdoe"); + } + + #[test] + fn filter_escape_preserves_safe_usernames() { + let safe = "jdoe"; + let escaped = ldap_escape(safe); + assert_eq!(escaped.as_ref(), "jdoe"); + } + + #[test] + fn fallback_dn_login_is_deterministic() { + let claims = LdapUserClaims { + server_url: "ldap://ldap.example.com".to_string(), + dn: "uid=test,ou=users,dc=example,dc=com".to_string(), + login: None, + email: None, + display_name: None, + groups: vec![], + }; + let a = fallback_dn_login(&claims); + let b = fallback_dn_login(&claims); + assert_eq!(a, b); + assert!(a.starts_with("ldap:")); + assert_eq!(a.len(), "ldap:".len() + 24); + } +} diff --git a/crates/api/src/auth/mod.rs b/crates/api/src/auth/mod.rs index 0fa33a2..1f18570 100644 --- a/crates/api/src/auth/mod.rs +++ b/crates/api/src/auth/mod.rs @@ -1,6 +1,7 @@ //! Authentication and authorization module pub mod jwt; +pub mod ldap; pub mod middleware; pub mod oidc; pub mod password; diff --git a/crates/api/src/dto/auth.rs b/crates/api/src/dto/auth.rs index e9fdec8..923ce98 100644 --- a/crates/api/src/dto/auth.rs +++ b/crates/api/src/dto/auth.rs @@ -172,6 +172,26 @@ pub struct AuthSettingsResponse { #[schema(example = "https://auth.example.com/assets/logo.svg")] pub oidc_provider_icon_url: Option, + /// Whether LDAP login is configured and enabled. + #[schema(example = false)] + pub ldap_enabled: bool, + + /// Whether LDAP login should be shown by default. + #[schema(example = false)] + pub ldap_visible_by_default: bool, + + /// Provider name for `?auth=`. + #[schema(example = "ldap")] + pub ldap_provider_name: Option, + + /// User-facing provider label for the login button. + #[schema(example = "Company LDAP")] + pub ldap_provider_label: Option, + + /// Optional icon URL shown beside the provider label. + #[schema(example = "https://ldap.example.com/assets/logo.svg")] + pub ldap_provider_icon_url: Option, + /// Whether unauthenticated self-service registration is allowed. #[schema(example = false)] pub self_registration_enabled: bool, diff --git a/crates/api/src/openapi.rs b/crates/api/src/openapi.rs index 7bf73e3..a4900c6 100644 --- a/crates/api/src/openapi.rs +++ b/crates/api/src/openapi.rs @@ -70,6 +70,7 @@ use crate::dto::{ // Authentication crate::routes::auth::auth_settings, crate::routes::auth::login, + crate::routes::auth::ldap_login, crate::routes::auth::register, crate::routes::auth::refresh_token, crate::routes::auth::get_current_user, @@ -239,6 +240,7 @@ use crate::dto::{ // Auth DTOs LoginRequest, + crate::routes::auth::LdapLoginRequest, RegisterRequest, RefreshTokenRequest, ChangePasswordRequest, @@ -453,4 +455,43 @@ mod tests { println!("Total API paths: {}", path_count); println!("Total API operations: {}", operation_count); } + + #[test] + fn test_auth_endpoints_registered() { + let doc = ApiDoc::openapi(); + + let expected_auth_paths = vec![ + "/auth/settings", + "/auth/login", + "/auth/ldap/login", + "/auth/register", + "/auth/refresh", + "/auth/me", + "/auth/change-password", + ]; + + for path in &expected_auth_paths { + assert!( + doc.paths.paths.contains_key(*path), + "Expected auth endpoint {} to be registered in OpenAPI spec, but it was missing. \ + Registered paths: {:?}", + path, + doc.paths.paths.keys().collect::>() + ); + } + } + + #[test] + fn test_ldap_login_request_schema_registered() { + let doc = ApiDoc::openapi(); + + let components = doc.components.as_ref().expect("components should exist"); + + assert!( + components.schemas.contains_key("LdapLoginRequest"), + "Expected LdapLoginRequest schema to be registered in OpenAPI components. \ + Registered schemas: {:?}", + components.schemas.keys().collect::>() + ); + } } diff --git a/crates/api/src/routes/auth.rs b/crates/api/src/routes/auth.rs index 43b81b3..0c40615 100644 --- a/crates/api/src/routes/auth.rs +++ b/crates/api/src/routes/auth.rs @@ -74,6 +74,7 @@ pub fn routes() -> Router { .route("/login", post(login)) .route("/oidc/login", get(oidc_login)) .route("/callback", get(oidc_callback)) + .route("/ldap/login", post(ldap_login)) .route("/logout", get(logout)) .route("/register", post(register)) .route("/refresh", post(refresh_token)) @@ -104,6 +105,13 @@ pub async fn auth_settings( .as_ref() .filter(|oidc| oidc.enabled); + let ldap = state + .config + .security + .ldap + .as_ref() + .filter(|ldap| ldap.enabled); + let response = AuthSettingsResponse { authentication_enabled: state.config.security.enable_auth, local_password_enabled: state.config.security.enable_auth, @@ -112,9 +120,21 @@ pub async fn auth_settings( oidc_enabled: oidc.is_some(), oidc_visible_by_default: oidc.is_some() && state.config.security.login_page.show_oidc_login, oidc_provider_name: oidc.map(|oidc| oidc.provider_name.clone()), - oidc_provider_label: oidc - .map(|oidc| oidc.provider_label.clone().unwrap_or_else(|| oidc.provider_name.clone())), + oidc_provider_label: oidc.map(|oidc| { + oidc.provider_label + .clone() + .unwrap_or_else(|| oidc.provider_name.clone()) + }), oidc_provider_icon_url: oidc.and_then(|oidc| oidc.provider_icon_url.clone()), + ldap_enabled: ldap.is_some(), + ldap_visible_by_default: ldap.is_some() && state.config.security.login_page.show_ldap_login, + ldap_provider_name: ldap.map(|ldap| ldap.provider_name.clone()), + ldap_provider_label: ldap.map(|ldap| { + ldap.provider_label + .clone() + .unwrap_or_else(|| ldap.provider_name.clone()) + }), + ldap_provider_icon_url: ldap.and_then(|ldap| ldap.provider_icon_url.clone()), self_registration_enabled: state.config.security.allow_self_registration, }; @@ -369,6 +389,17 @@ pub async fn get_current_user( Ok(Json(ApiResponse::new(response))) } +/// Request body for LDAP login. +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct LdapLoginRequest { + /// User login name (uid, sAMAccountName, etc.) + #[validate(length(min = 1, max = 255))] + pub login: String, + /// User password + #[validate(length(min = 1, max = 512))] + pub password: String, +} + #[derive(Debug, Deserialize)] pub struct OidcLoginParams { pub redirect_to: Option, @@ -401,6 +432,34 @@ pub async fn oidc_callback( ) } +/// Authenticate via LDAP directory. +/// +/// POST /auth/ldap/login +#[utoipa::path( + post, + path = "/auth/ldap/login", + tag = "auth", + request_body = LdapLoginRequest, + responses( + (status = 200, description = "Successfully authenticated via LDAP", body = inline(ApiResponse)), + (status = 401, description = "Invalid LDAP credentials"), + (status = 501, description = "LDAP not configured") + ) +)] +pub async fn ldap_login( + State(state): State, + Json(payload): Json, +) -> Result>, ApiError> { + payload + .validate() + .map_err(|e| ApiError::ValidationError(format!("Invalid LDAP login request: {e}")))?; + + let authenticated = + crate::auth::ldap::authenticate(&state, &payload.login, &payload.password).await?; + + Ok(Json(ApiResponse::new(authenticated.token_response))) +} + /// Logout the current browser session and optionally redirect through the provider logout flow. pub async fn logout( State(state): State, diff --git a/crates/api/tests/health_and_auth_tests.rs b/crates/api/tests/health_and_auth_tests.rs index cdc1a5c..ced7d96 100644 --- a/crates/api/tests/health_and_auth_tests.rs +++ b/crates/api/tests/health_and_auth_tests.rs @@ -305,6 +305,126 @@ async fn test_login_nonexistent_user() { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } +// ── LDAP auth tests ────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_ldap_login_returns_501_when_not_configured() { + let ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + let response = ctx + .post( + "/auth/ldap/login", + json!({ + "login": "jdoe", + "password": "secret" + }), + None, + ) + .await + .expect("Failed to make request"); + + // LDAP is not configured in config.test.yaml, so the endpoint + // should return 501 Not Implemented. + assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_ldap_login_validates_empty_login() { + let ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + let response = ctx + .post( + "/auth/ldap/login", + json!({ + "login": "", + "password": "secret" + }), + None, + ) + .await + .expect("Failed to make request"); + + // Validation should fail before we even check LDAP config + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_ldap_login_validates_empty_password() { + let ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + let response = ctx + .post( + "/auth/ldap/login", + json!({ + "login": "jdoe", + "password": "" + }), + None, + ) + .await + .expect("Failed to make request"); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_ldap_login_validates_missing_fields() { + let ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + let response = ctx + .post("/auth/ldap/login", json!({}), None) + .await + .expect("Failed to make request"); + + // Missing required fields should return 422 + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); +} + +// ── auth/settings LDAP field tests ────────────────────────────────── + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_auth_settings_includes_ldap_fields_disabled() { + let ctx = TestContext::new() + .await + .expect("Failed to create test context"); + + let response = ctx + .get("/auth/settings", None) + .await + .expect("Failed to make request"); + + assert_eq!(response.status(), StatusCode::OK); + + let body: serde_json::Value = response.json().await.expect("Failed to parse JSON"); + + // LDAP is not configured in config.test.yaml, so these should all + // reflect the disabled state. + assert_eq!(body["data"]["ldap_enabled"], false); + assert_eq!(body["data"]["ldap_visible_by_default"], false); + assert!(body["data"]["ldap_provider_name"].is_null()); + assert!(body["data"]["ldap_provider_label"].is_null()); + assert!(body["data"]["ldap_provider_icon_url"].is_null()); + + // Existing fields should still be present + assert!(body["data"]["authentication_enabled"].is_boolean()); + assert!(body["data"]["local_password_enabled"].is_boolean()); + assert!(body["data"]["oidc_enabled"].is_boolean()); + assert!(body["data"]["self_registration_enabled"].is_boolean()); +} + #[tokio::test] #[ignore = "integration test — requires database"] async fn test_get_current_user() { diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 83e9abe..9856c3e 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -307,6 +307,10 @@ pub struct SecurityConfig { /// Optional OpenID Connect configuration for browser login. #[serde(default)] pub oidc: Option, + + /// Optional LDAP configuration for username/password login against a directory. + #[serde(default)] + pub ldap: Option, } fn default_jwt_access_expiration() -> u64 { @@ -327,6 +331,10 @@ pub struct LoginPageConfig { /// Show the OIDC/SSO option by default when configured. #[serde(default = "default_true")] pub show_oidc_login: bool, + + /// Show the LDAP option by default when configured. + #[serde(default = "default_true")] + pub show_ldap_login: bool, } impl Default for LoginPageConfig { @@ -334,6 +342,7 @@ impl Default for LoginPageConfig { Self { show_local_login: true, show_oidc_login: true, + show_ldap_login: true, } } } @@ -379,6 +388,95 @@ fn default_oidc_provider_name() -> String { "oidc".to_string() } +/// LDAP authentication configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LdapConfig { + /// Enable LDAP login flow. + #[serde(default)] + pub enabled: bool, + + /// LDAP server URL (e.g., "ldap://ldap.example.com:389" or "ldaps://ldap.example.com:636"). + pub url: String, + + /// Bind DN template. Use `{login}` as placeholder for the user-supplied login. + /// Example: "uid={login},ou=users,dc=example,dc=com" + /// If not set, an anonymous bind is attempted first to search for the user. + pub bind_dn_template: Option, + + /// Base DN for user searches when bind_dn_template is not set. + /// Example: "ou=users,dc=example,dc=com" + pub user_search_base: Option, + + /// LDAP search filter template. Use `{login}` as placeholder. + /// Default: "(uid={login})" + #[serde(default = "default_ldap_user_filter")] + pub user_filter: String, + + /// DN of a service account used to search for users (required when using search-based auth). + pub search_bind_dn: Option, + + /// Password for the search service account. + pub search_bind_password: Option, + + /// LDAP attribute to use as the login name. Default: "uid" + #[serde(default = "default_ldap_login_attr")] + pub login_attr: String, + + /// LDAP attribute to use as the email. Default: "mail" + #[serde(default = "default_ldap_email_attr")] + pub email_attr: String, + + /// LDAP attribute to use as the display name. Default: "cn" + #[serde(default = "default_ldap_display_name_attr")] + pub display_name_attr: String, + + /// LDAP attribute that contains group membership. Default: "memberOf" + #[serde(default = "default_ldap_group_attr")] + pub group_attr: String, + + /// Whether to use STARTTLS. Default: false + #[serde(default)] + pub starttls: bool, + + /// Whether to skip TLS certificate verification (insecure!). Default: false + #[serde(default)] + pub danger_skip_tls_verify: bool, + + /// Provider name used in login-page overrides such as `?auth=`. + #[serde(default = "default_ldap_provider_name")] + pub provider_name: String, + + /// User-facing provider label shown on the login page. + pub provider_label: Option, + + /// Optional icon URL shown beside the provider label on the login page. + pub provider_icon_url: Option, +} + +fn default_ldap_provider_name() -> String { + "ldap".to_string() +} + +fn default_ldap_user_filter() -> String { + "(uid={login})".to_string() +} + +fn default_ldap_login_attr() -> String { + "uid".to_string() +} + +fn default_ldap_email_attr() -> String { + "mail".to_string() +} + +fn default_ldap_display_name_attr() -> String { + "cn".to_string() +} + +fn default_ldap_group_attr() -> String { + "memberOf".to_string() +} + /// Worker configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerConfig { @@ -753,6 +851,7 @@ impl Default for SecurityConfig { allow_self_registration: false, login_page: LoginPageConfig::default(), oidc: None, + ldap: None, } } } @@ -1035,6 +1134,7 @@ mod tests { allow_self_registration: false, login_page: LoginPageConfig::default(), oidc: None, + ldap: None, }, worker: None, sensor: None, @@ -1057,4 +1157,102 @@ mod tests { config.security.jwt_secret = None; assert!(config.validate().is_err()); } + + #[test] + fn test_ldap_config_defaults() { + let yaml = r#" +enabled: true +url: "ldap://localhost:389" +client_id: "test" +"#; + let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap(); + + assert!(cfg.enabled); + assert_eq!(cfg.url, "ldap://localhost:389"); + assert_eq!(cfg.user_filter, "(uid={login})"); + assert_eq!(cfg.login_attr, "uid"); + assert_eq!(cfg.email_attr, "mail"); + assert_eq!(cfg.display_name_attr, "cn"); + assert_eq!(cfg.group_attr, "memberOf"); + assert_eq!(cfg.provider_name, "ldap"); + assert!(!cfg.starttls); + assert!(!cfg.danger_skip_tls_verify); + assert!(cfg.bind_dn_template.is_none()); + assert!(cfg.user_search_base.is_none()); + assert!(cfg.search_bind_dn.is_none()); + assert!(cfg.search_bind_password.is_none()); + assert!(cfg.provider_label.is_none()); + assert!(cfg.provider_icon_url.is_none()); + } + + #[test] + fn test_ldap_config_full_deserialization() { + let yaml = r#" +enabled: true +url: "ldaps://ldap.corp.com:636" +bind_dn_template: "uid={login},ou=people,dc=corp,dc=com" +user_search_base: "ou=people,dc=corp,dc=com" +user_filter: "(sAMAccountName={login})" +search_bind_dn: "cn=svc,dc=corp,dc=com" +search_bind_password: "secret" +login_attr: "sAMAccountName" +email_attr: "userPrincipalName" +display_name_attr: "displayName" +group_attr: "memberOf" +starttls: true +danger_skip_tls_verify: true +provider_name: "corpldap" +provider_label: "Corporate Directory" +provider_icon_url: "https://corp.com/icon.svg" +"#; + let cfg: LdapConfig = serde_yaml_ng::from_str(yaml).unwrap(); + + assert!(cfg.enabled); + assert_eq!(cfg.url, "ldaps://ldap.corp.com:636"); + assert_eq!( + cfg.bind_dn_template.as_deref(), + Some("uid={login},ou=people,dc=corp,dc=com") + ); + assert_eq!( + cfg.user_search_base.as_deref(), + Some("ou=people,dc=corp,dc=com") + ); + assert_eq!(cfg.user_filter, "(sAMAccountName={login})"); + assert_eq!(cfg.search_bind_dn.as_deref(), Some("cn=svc,dc=corp,dc=com")); + assert_eq!(cfg.search_bind_password.as_deref(), Some("secret")); + assert_eq!(cfg.login_attr, "sAMAccountName"); + assert_eq!(cfg.email_attr, "userPrincipalName"); + assert_eq!(cfg.display_name_attr, "displayName"); + assert_eq!(cfg.group_attr, "memberOf"); + assert!(cfg.starttls); + assert!(cfg.danger_skip_tls_verify); + assert_eq!(cfg.provider_name, "corpldap"); + assert_eq!(cfg.provider_label.as_deref(), Some("Corporate Directory")); + assert_eq!( + cfg.provider_icon_url.as_deref(), + Some("https://corp.com/icon.svg") + ); + } + + #[test] + fn test_security_config_ldap_none_by_default() { + let yaml = r#"jwt_secret: "s""#; + let cfg: SecurityConfig = serde_yaml_ng::from_str(yaml).unwrap(); + + assert!(cfg.ldap.is_none()); + } + + #[test] + fn test_login_page_show_ldap_default_true() { + let cfg: LoginPageConfig = serde_yaml_ng::from_str("{}").unwrap(); + + assert!(cfg.show_ldap_login); + } + + #[test] + fn test_login_page_show_ldap_explicit_false() { + let cfg: LoginPageConfig = serde_yaml_ng::from_str("show_ldap_login: false").unwrap(); + + assert!(!cfg.show_ldap_login); + } } diff --git a/crates/common/src/repositories/identity.rs b/crates/common/src/repositories/identity.rs index d753703..4c80845 100644 --- a/crates/common/src/repositories/identity.rs +++ b/crates/common/src/repositories/identity.rs @@ -180,6 +180,27 @@ impl IdentityRepository { .await .map_err(Into::into) } + + pub async fn find_by_ldap_dn<'e, E>( + executor: E, + server_url: &str, + dn: &str, + ) -> Result> + where + E: Executor<'e, Database = Postgres> + 'e, + { + sqlx::query_as::<_, Identity>( + "SELECT id, login, display_name, password_hash, attributes, created, updated + FROM identity + WHERE attributes->'ldap'->>'server_url' = $1 + AND attributes->'ldap'->>'dn' = $2", + ) + .bind(server_url) + .bind(dn) + .fetch_optional(executor) + .await + .map_err(Into::into) + } } // Permission Set Repository diff --git a/crates/common/tests/identity_repository_tests.rs b/crates/common/tests/identity_repository_tests.rs index ef39e61..ec24722 100644 --- a/crates/common/tests/identity_repository_tests.rs +++ b/crates/common/tests/identity_repository_tests.rs @@ -479,3 +479,173 @@ async fn test_identity_login_case_sensitive() { .unwrap(); assert_eq!(found_upper.id, identity2.id); } + +// ── LDAP-specific tests ────────────────────────────────────────────────────── + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_find_by_ldap_dn_found() { + let pool = create_test_pool().await.unwrap(); + + let login = unique_pack_ref("ldap_found"); + let server_url = "ldap://ldap.example.com"; + let dn = "uid=jdoe,ou=users,dc=example,dc=com"; + + let input = CreateIdentityInput { + login: login.clone(), + display_name: Some("LDAP User".to_string()), + attributes: json!({ + "ldap": { + "server_url": server_url, + "dn": dn, + "login": "jdoe", + "email": "jdoe@example.com" + } + }), + password_hash: None, + }; + + let created = IdentityRepository::create(&pool, input).await.unwrap(); + + let found = IdentityRepository::find_by_ldap_dn(&pool, server_url, dn) + .await + .unwrap() + .expect("LDAP identity not found"); + + assert_eq!(found.id, created.id); + assert_eq!(found.login, login); + assert_eq!(found.attributes["ldap"]["server_url"], server_url); + assert_eq!(found.attributes["ldap"]["dn"], dn); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_find_by_ldap_dn_not_found() { + let pool = create_test_pool().await.unwrap(); + + let found = IdentityRepository::find_by_ldap_dn( + &pool, + "ldap://nonexistent.example.com", + "uid=nobody,ou=users,dc=example,dc=com", + ) + .await + .unwrap(); + + assert!(found.is_none()); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_find_by_ldap_dn_wrong_server() { + let pool = create_test_pool().await.unwrap(); + + let dn = "uid=jdoe,ou=users,dc=example,dc=com"; + + let input = CreateIdentityInput { + login: unique_pack_ref("ldap_wrong_srv"), + display_name: Some("Server A User".to_string()), + attributes: json!({ + "ldap": { + "server_url": "ldap://server-a.example.com", + "dn": dn, + "login": "jdoe" + } + }), + password_hash: None, + }; + + IdentityRepository::create(&pool, input).await.unwrap(); + + // Search with same DN but different server — composite key must match both + let found = IdentityRepository::find_by_ldap_dn(&pool, "ldap://server-b.example.com", dn) + .await + .unwrap(); + + assert!(found.is_none()); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_find_by_ldap_dn_multiple_identities_different_servers() { + let pool = create_test_pool().await.unwrap(); + + let dn = "uid=shared,ou=users,dc=example,dc=com"; + let server_a = "ldap://multi-a.example.com"; + let server_b = "ldap://multi-b.example.com"; + + let input_a = CreateIdentityInput { + login: unique_pack_ref("ldap_multi_a"), + display_name: Some("User on Server A".to_string()), + attributes: json!({ + "ldap": { + "server_url": server_a, + "dn": dn, + "login": "shared_a" + } + }), + password_hash: None, + }; + let identity_a = IdentityRepository::create(&pool, input_a).await.unwrap(); + + let input_b = CreateIdentityInput { + login: unique_pack_ref("ldap_multi_b"), + display_name: Some("User on Server B".to_string()), + attributes: json!({ + "ldap": { + "server_url": server_b, + "dn": dn, + "login": "shared_b" + } + }), + password_hash: None, + }; + let identity_b = IdentityRepository::create(&pool, input_b).await.unwrap(); + + // Query server A — should return identity_a + let found_a = IdentityRepository::find_by_ldap_dn(&pool, server_a, dn) + .await + .unwrap() + .expect("Identity for server A not found"); + assert_eq!(found_a.id, identity_a.id); + assert_eq!(found_a.attributes["ldap"]["server_url"], server_a); + + // Query server B — should return identity_b + let found_b = IdentityRepository::find_by_ldap_dn(&pool, server_b, dn) + .await + .unwrap() + .expect("Identity for server B not found"); + assert_eq!(found_b.id, identity_b.id); + assert_eq!(found_b.attributes["ldap"]["server_url"], server_b); + + // Confirm they are distinct identities + assert_ne!(found_a.id, found_b.id); +} + +#[tokio::test] +#[ignore = "integration test — requires database"] +async fn test_find_by_ldap_dn_ignores_oidc_attributes() { + let pool = create_test_pool().await.unwrap(); + + // Create an identity with OIDC attributes (no "ldap" key) + let input = CreateIdentityInput { + login: unique_pack_ref("ldap_oidc"), + display_name: Some("OIDC User".to_string()), + attributes: json!({ + "oidc": { + "issuer": "https://auth.example.com", + "subject": "abc123", + "email": "oidc@example.com" + } + }), + password_hash: None, + }; + + IdentityRepository::create(&pool, input).await.unwrap(); + + // Searching by LDAP DN should not match OIDC-only identities + let found = IdentityRepository::find_by_ldap_dn(&pool, "https://auth.example.com", "abc123") + .await + .unwrap(); + + assert!(found.is_none()); +} diff --git a/docs/deployment/gitea-registry-and-helm.md b/docs/deployment/gitea-registry-and-helm.md index a10c516..1fbc35c 100644 --- a/docs/deployment/gitea-registry-and-helm.md +++ b/docs/deployment/gitea-registry-and-helm.md @@ -26,10 +26,11 @@ The Helm chart is pushed as an OCI chart to: ## Required Gitea Repository Configuration -Set these repository variables: +Set these variables: -- `CONTAINER_REGISTRY_HOST`: Registry hostname only, for example `gitea.example.com` +- `CLUSTER_GITEA_HOST`: Registry hostname only, for example `gitea.example.com` - `CONTAINER_REGISTRY_NAMESPACE`: Optional override for the registry namespace. If omitted, the workflow uses the repository owner. +- `CONTAINER_REGISTRY_INSECURE`: Optional boolean toggle for plain HTTP registries. Set to `true` for cluster-internal registries such as `gitea-http.gitea.svc.cluster.local`. Set one of these authentication options: @@ -63,6 +64,12 @@ Log in to the registry: helm registry login gitea.example.com --username ``` +For a plain HTTP internal registry: + +```bash +helm registry login gitea-http.gitea.svc.cluster.local --username --plain-http +``` + Install the chart: ```bash diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index 1e5150c..35b3cb8 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -19,6 +19,11 @@ interface AuthSettingsResponse { oidc_provider_name: string | null; oidc_provider_label: string | null; oidc_provider_icon_url: string | null; + ldap_enabled: boolean; + ldap_visible_by_default: boolean; + ldap_provider_name: string | null; + ldap_provider_label: string | null; + ldap_provider_icon_url: string | null; self_registration_enabled: boolean; } @@ -33,6 +38,12 @@ export default function LoginPage() { const [isLoadingSettings, setIsLoadingSettings] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); const [credentials, setCredentials] = useState({ login: "", password: "" }); + const [ldapCredentials, setLdapCredentials] = useState({ + login: "", + password: "", + }); + const [ldapError, setLdapError] = useState(null); + const [isLdapSubmitting, setIsLdapSubmitting] = useState(false); const redirectPath = sessionStorage.getItem("redirect_after_login"); const from = @@ -67,19 +78,36 @@ export default function LoginPage() { const providerName = settings?.oidc_provider_name?.toLowerCase() ?? null; const providerLabel = settings?.oidc_provider_label ?? settings?.oidc_provider_name ?? "SSO"; + const ldapEnabled = settings?.ldap_enabled ?? false; + const ldapProviderName = settings?.ldap_provider_name?.toLowerCase() ?? null; + const ldapProviderLabel = + settings?.ldap_provider_label ?? settings?.ldap_provider_name ?? "LDAP"; let showLocal = settings?.local_password_visible_by_default ?? false; let showOidc = settings?.oidc_visible_by_default ?? false; + let showLdap = settings?.ldap_visible_by_default ?? false; if (authOverride === "direct") { if (localEnabled) { showLocal = true; showOidc = false; + showLdap = false; } } else if (authOverride && providerName && authOverride === providerName) { if (oidcEnabled) { showLocal = false; showOidc = true; + showLdap = false; + } + } else if ( + authOverride && + ldapProviderName && + authOverride === ldapProviderName + ) { + if (ldapEnabled) { + showLocal = false; + showOidc = false; + showLdap = true; } } @@ -107,10 +135,29 @@ export default function LoginPage() { return; } + if (ldapProviderName && authOverride === ldapProviderName) { + setOverrideError( + ldapEnabled + ? null + : `${ldapProviderLabel} was requested, but it is not available on this server.`, + ); + return; + } + setOverrideError( `Unknown authentication override '${authOverride}'. Falling back to the server defaults.`, ); - }, [authOverride, localEnabled, oidcEnabled, providerLabel, providerName, settings]); + }, [ + authOverride, + localEnabled, + oidcEnabled, + providerLabel, + providerName, + ldapEnabled, + ldapProviderLabel, + ldapProviderName, + settings, + ]); const handleOidcLogin = () => { sessionStorage.setItem("redirect_after_login", from); @@ -143,6 +190,37 @@ export default function LoginPage() { } }; + const handleLdapLogin = async (event: FormEvent) => { + event.preventDefault(); + setLdapError(null); + setIsLdapSubmitting(true); + + try { + const response = await apiClient.post<{ + data: { access_token: string; refresh_token: string }; + }>("/auth/ldap/login", ldapCredentials); + await completeLogin({ + accessToken: response.data.data.access_token, + refreshToken: response.data.data.refresh_token, + }); + sessionStorage.removeItem("redirect_after_login"); + navigate(from, { replace: true }); + } catch (error) { + if (error && typeof error === "object" && "response" in error) { + const axiosError = error as { + response?: { data?: { message?: string } }; + }; + setLdapError( + axiosError.response?.data?.message ?? "LDAP authentication failed.", + ); + } else { + setLdapError("LDAP authentication failed."); + } + } finally { + setIsLdapSubmitting(false); + } + }; + return (
@@ -272,12 +350,93 @@ export default function LoginPage() { ) : null} - {!settingsError && authEnabled && !showLocal && !showOidc ? ( + {authEnabled && (showLocal || showOidc) && showLdap ? ( +
+
+ or +
+
+ ) : null} + + {authEnabled && showLdap ? ( + <> +

+ Sign in with {ldapProviderLabel}. +

+
+
+ + + setLdapCredentials((current) => ({ + ...current, + login: event.target.value, + })) + } + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + required + /> +
+
+ + + setLdapCredentials((current) => ({ + ...current, + password: event.target.value, + })) + } + className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500" + required + /> +
+ {ldapError ? ( +
+ {ldapError} +
+ ) : null} + +
+ + ) : null} + + {!settingsError && + authEnabled && + !showLocal && + !showOidc && + !showLdap ? (
No login method is shown by default for this server. Use `?auth=direct` - {providerName ? ` or ?auth=${providerName}` : ""} to choose - a specific method. + {providerName ? ` or ?auth=${providerName}` : ""} + {ldapProviderName ? ` or ?auth=${ldapProviderName}` : ""} to + choose a specific method.
) : null} diff --git a/work-summary/2026-03-19-ldap-authentication.md b/work-summary/2026-03-19-ldap-authentication.md new file mode 100644 index 0000000..781c318 --- /dev/null +++ b/work-summary/2026-03-19-ldap-authentication.md @@ -0,0 +1,63 @@ +# LDAP Authentication Support + +**Date**: 2026-03-19 + +## Summary + +Added LDAP as an authentication provider alongside the existing OIDC and local username/password login methods. LDAP authentication follows the same architectural patterns as OIDC — server-side credential verification, identity upsert with provider-specific claims stored in the `attributes` JSONB column, and JWT token issuance. + +## Changes + +### Backend (Rust) + +#### New Files +- **`crates/api/src/auth/ldap.rs`** — LDAP authentication module using the `ldap3` crate (v0.12). Supports two authentication modes: + - **Direct bind**: Constructs a DN from a configurable `bind_dn_template` (e.g., `uid={login},ou=users,dc=example,dc=com`) and binds directly as the user. + - **Search-and-bind**: Binds as a service account (or anonymous), searches for the user entry using `user_search_base` + `user_filter`, then re-binds as the discovered DN with the user's password. + - After successful authentication, fetches user attributes (login, email, display name, groups) and upserts an identity row with claims stored under `attributes.ldap`. + +#### Modified Files +- **`crates/common/src/config.rs`**: + - Added `LdapConfig` struct with fields for server URL, bind DN template, search base/filter, service account credentials, attribute mapping, TLS settings, and UI metadata (provider name/label/icon). + - Added `ldap: Option` to `SecurityConfig`. + - Added `show_ldap_login: bool` to `LoginPageConfig`. + +- **`crates/common/src/repositories/identity.rs`**: + - Added `find_by_ldap_dn()` method to `IdentityRepository`, querying `attributes->'ldap'->>'server_url'` and `attributes->'ldap'->>'dn'` (mirrors the existing `find_by_oidc_subject` pattern). + +- **`crates/api/Cargo.toml`**: + - Added `ldap3 = "0.12"` dependency. + +- **`crates/api/src/auth/mod.rs`**: + - Added `pub mod ldap;`. + +- **`crates/api/src/routes/auth.rs`**: + - Added `POST /auth/ldap/login` route and `ldap_login` handler (validates `LdapLoginRequest`, delegates to `ldap::authenticate`, returns `TokenResponse`). + - Updated `auth_settings` handler to populate LDAP fields in the response. + +- **`crates/api/src/dto/auth.rs`**: + - Added `ldap_enabled`, `ldap_visible_by_default`, `ldap_provider_name`, `ldap_provider_label`, `ldap_provider_icon_url` fields to `AuthSettingsResponse`. + +### Frontend (React/TypeScript) + +- **`web/src/pages/auth/LoginPage.tsx`**: + - Extended `AuthSettingsResponse` interface with LDAP fields. + - Added LDAP login form (username/password) with emerald-colored submit button, error handling, and `?auth=ldap` override support. + - Added divider between sections when multiple login methods are visible. + +### Configuration + +- **`config.example.yaml`**: Added full LDAP configuration example with comments explaining direct-bind vs search-and-bind modes. +- **`config.development.yaml`**: Added disabled LDAP section with direct-bind template. + +### Documentation + +- **`AGENTS.md`**: Updated Authentication & Security section to document both OIDC and LDAP providers, their config keys, routes, identity matching, and login page configuration. + +## Architecture Notes + +- LDAP authentication is a **synchronous POST** flow (no browser redirects), unlike OIDC which uses authorization code redirects. The user submits credentials to `POST /auth/ldap/login` and receives JWT tokens directly. +- Identity deduplication uses `server_url + dn` as the composite key (stored in `attributes.ldap`), analogous to OIDC's `issuer + sub`. +- Login name collision avoidance uses the same SHA-256 fallback pattern as OIDC (`ldap:<24-hex-chars>`). +- The `ldap3` crate connection is driven asynchronously on the Tokio runtime via `ldap3::drive!(conn)`. +- STARTTLS and TLS certificate verification skip are configurable per-deployment. \ No newline at end of file