Skip to content

LDAP: Add mTLS / client certificate authentication support#509

Merged
steveiliop56 merged 6 commits intotinyauthapp:mainfrom
plaes:ldap-mtls
Dec 31, 2025
Merged

LDAP: Add mTLS / client certificate authentication support#509
steveiliop56 merged 6 commits intotinyauthapp:mainfrom
plaes:ldap-mtls

Conversation

@plaes
Copy link
Copy Markdown
Contributor

@plaes plaes commented Dec 10, 2025

It's now possible to connect to LDAP servers using client certificates. It was tested against Google's LDAP service (though only available for Google Workspace users):

export LDAP_ADDRESS="ldaps://ldap.google.com:636"
export LDAP_BASE_DN="dc=example,dc=com"
export LDAP_AUTH_CERT=Google_ldap_service.crt
export LDAP_AUTH_KEY=Google_ldap_service.key
# export LDAP_SEARCH_FILTER="(uid=%s)" ## <-- default works well for username-based users

When any of the ldap-auth-* variables is used, it will attempt to load certificate-key pair and if something fails, it exits immedately ignoring ldap-bind-dn and ldap-bind-password values.

Also added note about missing STARTTLS support.

Summary by CodeRabbit

  • New Features
    • LDAP authentication now supports mutual TLS (mTLS) using a client certificate and key.
    • LDAP configuration adds fields to specify client certificate and key paths.
    • LDAP service account binding updated to prefer an mTLS-capable binding flow when a client certificate is provided.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Dec 10, 2025

📝 Walkthrough

Walkthrough

Adds LDAP client-certificate (mTLS) support and config fields; LDAP service loads a client cert at Init, switches TLS/bind behavior when a cert is present, and auth rebinds now call a new BindService method.

Changes

Cohort / File(s) Summary
LDAP configuration
internal/config/config.go
Added AuthCert and AuthKey string fields to LdapConfig (yaml: authCert, authKey).
LDAP service implementation
internal/service/ldap_service.go
Introduced mTLS: new cert *tls.Certificate field and loading of client cert during Init. LdapService.Config renamed to private config LdapServiceConfig; added AuthCert/AuthKey to that config. Added BindService(rebind bool) and updated connect() / bind logic to use certificate-aware TLS or fallback TLS. Search and lifecycle adjusted to use ldap.config.
Auth flow change
internal/service/auth_service.go
Replaced direct rebind using BindDN/BindPassword with LdapService.BindService(true) for service-account rebinds after user bind.
Bootstrap wiring
internal/bootstrap/service_bootstrap.go
Passes AuthCert and AuthKey from app config into service.LdapServiceConfig when constructing the LDAP service.

Sequence Diagram(s)

sequenceDiagram
    autonumber
    actor App as App Bootstrap
    participant LDAP as LdapService
    participant TLS as TLS Loader
    participant Conn as LDAP Connection
    participant Auth as Auth Service

    App->>LDAP: Init(config with AuthCert/AuthKey)
    LDAP->>TLS: load cert from AuthCert/AuthKey
    alt cert loaded
        TLS-->>LDAP: cert (*tls.Certificate)
        LDAP->>Conn: connect() using mTLS (client cert)
        Conn-->>LDAP: connected
        LDAP->>LDAP: BindService(false) -> ExternalBind with cert
    else no cert
        LDAP->>Conn: connect() using standard TLS (InsecureSkipVerify, TLS1.2+)
        Conn-->>LDAP: connected
        LDAP->>LDAP: BindService(false) -> Bind with BindDN/BindPassword
    end
    App->>Auth: Authenticate user
    Auth->>LDAP: user bind then LdapService.BindService(true) for rebind
    LDAP->>Conn: perform bind (ExternalBind or Bind)
    Conn-->>LDAP: bind result
    LDAP-->>Auth: result
    Auth-->>App: authentication complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I found a cert beneath a log,
I hopped and hugged it like a dog.
AuthCert and AuthKey in paw,
We bind with mTLS—hip hip hurrah! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: adding mTLS/client certificate authentication support to LDAP, which is the primary objective of the changeset.
✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@plaes plaes changed the title LDAP: Add mTLS or client certificate authentication support LDAP: Add mTLS / client certificate authentication support Dec 10, 2025
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
cmd/root.go (1)

68-69: Consider improving flag descriptions for clarity.

The descriptions are slightly inconsistent. Consider aligning them for better user experience:

-		{"ldap-auth-cert", "", "LDAP client certificate for authentication"},
-		{"ldap-auth-key", "", "LDAP client certificate authentication key"},
+		{"ldap-auth-cert", "", "Path to LDAP client certificate for mTLS authentication."},
+		{"ldap-auth-key", "", "Path to LDAP client private key for mTLS authentication."},
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2c1554a and be03f5e.

📒 Files selected for processing (5)
  • cmd/root.go (1 hunks)
  • internal/bootstrap/app_bootstrap.go (1 hunks)
  • internal/config/config.go (1 hunks)
  • internal/service/auth_service.go (1 hunks)
  • internal/service/ldap_service.go (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
internal/service/ldap_service.go (1)
internal/config/config.go (1)
  • Config (17-48)
🔇 Additional comments (5)
internal/config/config.go (1)

37-38: LGTM!

The new configuration fields for mTLS authentication follow the existing naming conventions and placement within the LDAP configuration block.

internal/service/ldap_service.go (2)

85-95: mTLS path ignores the Insecure configuration flag.

When a client certificate is provided, InsecureSkipVerify is not set in the TLS config (line 86-89), whereas the non-certificate path respects ldap.config.Insecure (line 92). If this is intentional (enforcing server cert validation when using mTLS), consider documenting this behavior. Otherwise, apply the flag consistently.


138-150: LGTM - BindService abstraction is well-designed.

The method correctly handles both mTLS (ExternalBind) and DN/password binding scenarios. The rebind parameter appropriately controls mutex acquisition to prevent deadlock when called from connect() which already holds the lock.

internal/bootstrap/app_bootstrap.go (1)

113-114: LGTM!

The new certificate configuration fields are correctly wired through to the LDAP service, following the established pattern for other LDAP configuration options.

internal/service/auth_service.go (1)

106-110: LGTM!

The change correctly delegates rebinding to BindService(true), which abstracts the binding mechanism (mTLS via ExternalBind vs. DN/password via Bind). This maintains the existing error handling flow while supporting both authentication methods.

Comment thread internal/service/ldap_service.go
Copy link
Copy Markdown
Member

@steveiliop56 steveiliop56 left a comment

Choose a reason for hiding this comment

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

Looks good @plaes, just left you two comments and we can merge.

Comment thread internal/service/ldap_service.go
Comment thread internal/service/ldap_service.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/service/ldap_service.go (1)

182-207: Race condition: Close() called without mutex protection.

The reconnect() method has a concurrency issue. At line 192, ldap.conn.Close() is called without holding the mutex, while all other accesses to ldap.conn (in Search, Bind, heartbeat) are protected by the mutex.

Race scenario:

  1. heartbeat() detects failure and returns error (releases lock)
  2. Heartbeat goroutine calls reconnect() (no lock held)
  3. operation closes connection at line 192 (no lock held)
  4. Meanwhile, another goroutine calls Search(), acquires lock, and attempts to use the closed connection
  5. This leads to undefined behavior or crashes

The fix should ensure ldap.conn.Close() is protected by the mutex, or ensure no other operations can access the connection during reconnection.

🔎 Potential fix approach

One approach is to acquire the lock before closing:

 func (ldap *LdapService) reconnect() error {
 	log.Info().Msg("Reconnecting to LDAP server")
 
 	exp := backoff.NewExponentialBackOff()
 	exp.InitialInterval = 500 * time.Millisecond
 	exp.RandomizationFactor = 0.1
 	exp.Multiplier = 1.5
 	exp.Reset()
 
 	operation := func() (*ldapgo.Conn, error) {
+		ldap.mutex.Lock()
 		ldap.conn.Close()
+		ldap.mutex.Unlock()
 		conn, err := ldap.connect()
 		if err != nil {
 			return nil, err
 		}
 		return conn, nil
 	}
 
 	_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
 
 	if err != nil {
 		return err
 	}
 
 	return nil
 }

Note: This approach holds the lock briefly during Close(), then releases it before connect() (which acquires its own lock). This prevents other goroutines from accessing the closed connection between Close() and connect().

♻️ Duplicate comments (1)
internal/service/ldap_service.go (1)

144-147: Remove unnecessary else for cleaner early return.

As previously noted by @steveiliop56, the else statement can be removed for a cleaner early-return pattern.

🔎 Proposed refactor
 	if ldap.cert != nil {
 		return ldap.conn.ExternalBind()
 	}
-	} else {
-		return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
-	}
+	return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 77cc8e2 and e5694cd.

📒 Files selected for processing (1)
  • internal/service/ldap_service.go
🔇 Additional comments (2)
internal/service/ldap_service.go (2)

22-23: LGTM! Clean structure for mTLS support.

The new AuthCert and AuthKey fields, the private config field, and the cert storage field are well-designed for certificate-based authentication. The encapsulation improvements are appropriate.

Also applies to: 27-27, 30-30, 35-35


84-94: Verify intentional omission of InsecureSkipVerify in mTLS path.

When using client certificates (lines 85-88), the InsecureSkipVerify option is not set, so it defaults to false (secure verification). In contrast, the non-mTLS path (lines 90-92) respects ldap.config.Insecure. This means users cannot disable server certificate verification when using mTLS.

While this is likely intentional for security (mTLS scenarios typically require mutual verification), it's a behavioral difference from the non-mTLS path. Confirm this is the desired behavior, especially for development or testing scenarios where self-signed certificates might be used.

Comment thread internal/service/ldap_service.go Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
internal/service/ldap_service.go (2)

40-48: Good implementation! Past review concerns addressed.

The certificate loading logic properly returns errors instead of calling log.Fatal(), allowing the caller to handle initialization failures gracefully. The typo fix and control flow improvements from previous reviews have been applied.

💡 Optional: Validate partial configuration

Consider adding validation to detect when only one of AuthCert/AuthKey is set, which likely indicates a configuration error:

+	if (ldap.config.AuthCert != "") != (ldap.config.AuthKey != "") {
+		return fmt.Errorf("both AuthCert and AuthKey must be specified together for mTLS authentication")
+	}
+
 	// Check whether authentication with client certificate is possible
 	if ldap.config.AuthCert != "" && ldap.config.AuthKey != "" {

This would provide clearer feedback to users who misconfigure the certificate authentication.


71-106: Well-designed TLS configuration handling.

The connect method properly handles both certificate-based and password-based authentication:

  • Conditional TLS configuration based on certificate presence is correct
  • Early return pattern makes the code more readable
  • The TODO comment clearly documents the STARTTLS limitation mentioned in the PR description
  • Proper locking and binding flow with BindService(false)

One consideration: when using client certificates (mTLS), the Insecure config flag is not applied to server certificate validation (it defaults to secure). While this is generally the right behavior for mTLS deployments, consider documenting this behavior if users might expect to combine client certificates with self-signed or invalid server certificates.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e5694cd and f3e3513.

📒 Files selected for processing (4)
  • internal/bootstrap/service_bootstrap.go
  • internal/config/config.go
  • internal/service/auth_service.go
  • internal/service/ldap_service.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/service/auth_service.go
🧰 Additional context used
🧬 Code graph analysis (1)
internal/service/ldap_service.go (1)
internal/config/config.go (1)
  • Config (17-32)
🔇 Additional comments (5)
internal/config/config.go (1)

69-70: LGTM! Clean configuration additions.

The new fields for mTLS authentication are properly declared with appropriate yaml tags and descriptions. The naming is clear and consistent with the existing LDAP configuration structure.

internal/bootstrap/service_bootstrap.go (1)

40-41: LGTM! Configuration properly wired.

The new certificate configuration fields are correctly passed to the LDAP service, following the same pattern as the existing LDAP configuration fields.

internal/service/ldap_service.go (3)

22-23: LGTM! Well-structured additions.

The new fields for mTLS support are cleanly integrated:

  • AuthCert and AuthKey added to configuration
  • Private cert field to store the loaded certificate
  • Config field renamed to private config following Go conventions

Also applies to: 27-27, 30-30, 35-35


108-135: LGTM! Field references correctly updated.

The Search method properly uses the private config field, consistent with the struct changes.


137-148: Excellent implementation of BindService!

The conditional locking design elegantly handles both initial binding (where the caller already holds the lock) and rebinding scenarios (where the method must acquire the lock). This prevents deadlock while maintaining thread safety.

The early return pattern (addressing a previous review comment) makes the logic clear: use ExternalBind for certificate-based authentication, otherwise fall back to standard binding with DN/password.

@plaes
Copy link
Copy Markdown
Contributor Author

plaes commented Dec 26, 2025

PR has been rebase against main and tested it against Google LDAP service.

Though there seem to be some regressions due to 03ed183:

  1. Configuration doesn't seem to be picked up from environment variables anymore (few examples: TINYAUTH_APPURL or TINYAUTH_LDAP_ADDRESS nor others seem to be working for me)
  2. Default searchfilter for LDAP is empty now. Previously it defaulted to (uid=%s).

@steveiliop56
Copy link
Copy Markdown
Member

@plaes no worries, I will look into them. Thank you for the pull request.

Comment thread internal/service/ldap_service.go
@steveiliop56
Copy link
Copy Markdown
Member

@plaes fixed the second issue you mentioned in b159f44, forgot to set the default. As for the first issue are you sure you configured everything correctly? I cannot reproduce. I also left you a small comment.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/service/ldap_service.go (1)

148-159: Clarify the locking rationale in the comment.

The conditional locking based on the rebind parameter is correct but subtle:

  • When rebind=false (initial bind from connect()), the caller already holds the mutex, so locking here would cause a deadlock.
  • When rebind=true (subsequent rebind from auth flow), the caller doesn't hold the mutex, so this method must acquire it.

Consider improving the comment to make this rationale more explicit, which will help future maintainers understand why the lock is conditional.

🔎 Suggested comment improvement
-	// Locks must not be used for initial binding attempt
+	// When rebind=false, the caller (connect) already holds the mutex.
+	// When rebind=true, this is called externally and must acquire the lock.
 	if rebind {
 		ldap.mutex.Lock()
 		defer ldap.mutex.Unlock()
 	}

Note: The else statement on lines 155-158 could also be removed as suggested in past comments, though this is a style preference:

if ldap.cert != nil {
	return ldap.conn.ExternalBind()
}
return ldap.conn.Bind(ldap.config.BindDN, ldap.config.BindPassword)
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f3e3513 and b00ff28.

📒 Files selected for processing (1)
  • internal/service/ldap_service.go
🧰 Additional context used
🧬 Code graph analysis (1)
internal/service/ldap_service.go (1)
internal/config/config.go (1)
  • Config (17-32)
🔇 Additional comments (6)
internal/service/ldap_service.go (6)

22-23: LGTM!

The new configuration fields for client certificate authentication are well-integrated and align with the PR objectives.


27-27: Good practice to make the config field private.

This reduces the public API surface and properly encapsulates the service's configuration.


30-30: LGTM!

The certificate field is appropriately typed as a pointer to support optional mTLS authentication.


40-59: LGTM!

The certificate loading logic correctly returns errors instead of calling log.Fatal(), allowing graceful error handling by the caller. The typo in the error message has also been fixed. The TODO comment about optional CA certificates is a reasonable placeholder for future enhancement.


110-116: LGTM!

The refactored binding logic correctly uses BindService(false) to perform the initial bind after establishing the connection. The false parameter indicates this is not a rebind operation, which is appropriate since connect() already holds the mutex lock.


95-99: ExternalBind is the correct SASL EXTERNAL mechanism for client certificate authentication with go-ldap/ldap/v3, and the implementation follows the expected pattern: configure TLS with the client certificate → DialURL with TLS config → ExternalBind() call.

Comment thread internal/service/ldap_service.go Outdated
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (2)
internal/service/ldap_service.go (2)

148-153: Consider clarifying the locking comment.

The conditional locking logic is correct (prevents double-locking when called from connect() which already holds the mutex at line 83), but the comment could be clearer about why locking is conditional.

🔎 Suggested comment clarification
 func (ldap *LdapService) BindService(rebind bool) error {
-	// Locks must not be used for initial binding attempt
+	// Lock is already held by connect() during initial bind (rebind=false).
+	// Only acquire lock when rebinding from external callers (rebind=true).
 	if rebind {
 		ldap.mutex.Lock()
 		defer ldap.mutex.Unlock()
 	}

49-58: Optional: CA certificate configuration for custom/private CAs.

The TODO correctly identifies a future enhancement for specifying custom CA certificates when connecting to LDAP servers with non-public CAs. This would eliminate the need for system-level CA trust configuration.

Do you want me to help implement this enhancement or open an issue to track it?

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b00ff28 and 5842e18.

📒 Files selected for processing (1)
  • internal/service/ldap_service.go
🔇 Additional comments (2)
internal/service/ldap_service.go (2)

40-47: LGTM! Certificate loading is properly implemented.

The certificate loading logic correctly uses tls.LoadX509KeyPair, returns errors appropriately (addressing the previous log.Fatal issue), and logs success. The conditional check ensures both cert and key are provided before attempting to load.


95-105: LGTM! TLS configuration correctly enforces security with mTLS.

The conditional TLS setup appropriately handles both certificate-based and standard authentication. When a client certificate is present, server certificate verification is always enforced (no InsecureSkipVerify), which is the correct security posture for mTLS as discussed in previous reviews.

@codecov
Copy link
Copy Markdown

codecov Bot commented Dec 31, 2025

Codecov Report

❌ Patch coverage is 0% with 34 lines in your changes missing coverage. Please review.
✅ Project coverage is 20.12%. Comparing base (43487d4) to head (5842e18).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
internal/service/ldap_service.go 0.00% 31 Missing ⚠️
internal/bootstrap/service_bootstrap.go 0.00% 2 Missing ⚠️
internal/service/auth_service.go 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #509      +/-   ##
==========================================
- Coverage   20.26%   20.12%   -0.14%     
==========================================
  Files          37       37              
  Lines        2147     2196      +49     
==========================================
+ Hits          435      442       +7     
- Misses       1682     1724      +42     
  Partials       30       30              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@steveiliop56 steveiliop56 merged commit f564032 into tinyauthapp:main Dec 31, 2025
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants