ldapauth

package module
v1.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 28 Imported by: 0

README

ldap-authenticator

CI Security Go Reference

Production LDAP authentication for Go, with one dependency.

Between go-ldap, which speaks the protocol and leaves every decision to you, and the login libraries that make every decision and bring a web framework with them. This one makes the decisions that are not really decisions — an empty password is never sent, a username is never concatenated into a filter, a pooled connection is never rebound as a user — and leaves the rest as options with defaults you can live with.

auth, err := ldapauth.New(
    ldapauth.WithURL("ldaps://dc1.corp.example:636"),
    ldapauth.WithDirectBind("uid={{username}},ou=people,dc=corp,dc=example"),
)
if err != nil {
    return err
}
defer auth.Close()

identity, err := auth.Authenticate(ctx, username, password)
switch {
case err == nil:                                     // authenticated
case errors.Is(err, ldapauth.ErrInvalidCredentials): // 401
default:                                             // 503
}

The only direct dependency is github.com/go-ldap/ldap/v3, and a CI job fails the build if that ever stops being true.

Everything is a seam

Seven interfaces, each with a working default, each replaceable on its own. None of them has to be thought about to use this package, and none of them costs a dependency.

replaces with
Authenticator what New returns a fake, or a decorator with a circuit breaker
Identity what Authenticate returns your application's own user type
PrincipalMapper how an entry becomes an identity WithPrincipalMapper
GroupResolver where groups come from WithGroupResolver
ByteStore where outcomes are remembered, over bytes WithSealedCacheStore
Cache the same, over decoded identities WithCacheStore
Dialer where connections come from WithDialer
Metrics where the numbers go WithMetrics

Identity's accessors are spelled GetDN, GetGroups, and so on, because Principal carries the same information as exported fields and a field and a method cannot share a name. It is the spelling go-ldap itself uses for ldap.Entry.

Principal is the Identity this package produces, and it stays a plain struct with exported fields — construct one in a test, read it in a handler:

if p, ok := ldapauth.AsPrincipal(identity); ok {
    log.Println(p.DN, p.Groups, p.Attributes)
}

Supply your own when the directory is not the whole story:

ldapauth.WithPrincipalMapper(ldapauth.PrincipalMapperFunc(
    func(m ldapauth.Mapping) (ldapauth.Identity, error) {
        return &user{dn: m.DN, name: m.Username, groups: m.Groups, tenant: tenantOf(m.Entry)}, nil
    },
))

What this is not

It is not a session library. Authenticate tells you who the directory says somebody is; what that becomes — a cookie, a JWT, a row in a table — is your application's decision, and a library that picked for you would be a library you had to work around.

It is not a directory client. There is no API here for creating users, changing passwords, or writing attributes. It reads what it needs to answer one question.

It is not a framework. The HTTP middleware is opt-in, lives behind its own import path, and the framework adapters live in separate modules so that importing this package never puts Gin in your build graph.

Install

go get github.com/ctolon/ldap-authenticator

And, if you want the framework middleware — each is its own module, with its own README:

go get github.com/ctolon/ldap-authenticator/contrib/gin
go get github.com/ctolon/ldap-authenticator/contrib/echo
go get github.com/ctolon/ldap-authenticator/contrib/fiber

net/http needs no extra module: httpauth is in the root module, because it needs nothing outside the standard library. See docs/http.md.

Two strategies, and how to pick

A deployment binds one of two ways, and which one is available is a question about your directory rather than about taste.

direct bind search then bind
Round trips one three
Needs a DN derivable from the username a service account that may read the user tree
Configure with WithDirectBind WithUserSearch + WithServiceAccount

Direct bind when users live in one subtree and log in with the attribute that names their entry:

ldapauth.WithDirectBind("uid={{username}},ou=people,dc=corp,dc=example")
ldapauth.WithDirectBind("{{username}}@corp.example")  // Active Directory UPN

Search then bind when users are spread across subtrees, or log in with something that is not part of their DN — an email address, an employee number:

ldapauth.WithServiceAccount("cn=readonly,dc=corp,dc=example", os.Getenv("LDAP_SERVICE_PASSWORD")),
ldapauth.WithUserSearch("ou=people,dc=corp,dc=example",
    "(&(objectClass=inetOrgPerson)(uid={{username}}))"),

A single %s is accepted in place of {{username}}, because that is the spelling most existing LDAP configuration in the world already uses.

Why the pooled connection is never rebound

The pool holds connections bound as the service account and nothing else. When a user's password is verified, that bind happens on a connection of its own, which is closed afterwards and never returned to the pool.

This is not an efficiency decision. A pooled connection rebound as a user carries that user's identity for whichever request picks it up next, and the searches that request makes run with their permissions. That is an authorisation bypass built out of an optimisation, and it is the most common way to get LDAP authentication subtly wrong.

Groups

Off by default — group resolution costs a round trip and not every application needs it. Turn on the source that matches the directory:

ldapauth.WithMemberOfGroups()                          // free; needs the memberOf attribute
ldapauth.WithGroupSearch(groupsDN, "(member={{user_dn}})")  // one search; works anywhere
ldapauth.WithActiveDirectoryGroups(groupsDN)           // one search, transitive; AD only
if identity.InAnyGroup("developers", "ops") { ... }

Nested groups walked from the client are bounded twice, by WithNestedGroupDepth and by a visited set, because a directory will happily let two groups contain each other. On Active Directory prefer WithActiveDirectoryGroups, which asks the server to do the whole expansion in one search.

HTTP

mux.Handle("GET /private", httpauth.BasicAuth(auth,
    httpauth.WithRealm("corp"),
    httpauth.RequireAnyGroup("developers"),
    httpauth.WithMinimumDuration(100*time.Millisecond),
)(handler))

identity := httpauth.MustIdentity(r.Context())

The signature is func(http.Handler) http.Handler, so it drops into chi, gorilla/mux, or a bare ServeMux unchanged. Gin, Echo, and Fiber get the same API from their own modules, over the same shared policy, so behaviour cannot drift between them.

LoginHandler reads JSON or form credentials and hands you the identity. It issues no session and no token, on purpose.

The status mapping is the whole of the error handling most callers need: 401 for wrong or absent credentials, 403 for a failed group requirement, 400 for a request that is not a login, 503 for a directory that could not answer.

Guarantees

  • A bind is never issued with an empty password, and the request is refused before a connection is opened. RFC 4511 makes a zero-length password an unauthenticated bind, which a conforming server answers with success.
  • Every value is escaped for the grammar it lands in — RFC 4515 for filters, RFC 4514 for DNs. There is no exported way to interpolate an unescaped value.
  • A wrong password and an unknown user are the same error, so a login form built on this package cannot become an account enumeration oracle by forgetting to collapse them.
  • A rejected credential is never retried, so this package cannot walk an account into a lockout policy.
  • Plaintext is refused unless StartTLS is configured or WithInsecureNoTLS is passed explicitly.
  • No password is logged, ever, and none is stored: the cache key is an HMAC under a random key, and a Cache implementation never sees a credential.
  • This package will not walk an account into a lockout. A rejected credential is never retried, and WithLockoutProtection stops repeated failures reaching the directory's own counter at all.

Each of these has a test named after what it protects. Eleven fuzz targets assert the properties rather than the outputs — including three that drive the whole package end to end against a directory that parses filters and DNs for real: no username may authenticate as somebody it is not, none may add a term or a wildcard to the user search, and none may widen a group filter.

Several replicas

Nothing is shared between processes unless you share it: each replica has its own pool, its own failover state, and its own cache.

To share a cache, give every replica the same secret and a store that points at the same place. Without the shared secret each replica derives a different key for the same login, and the shared cache silently never hits:

ldapauth.WithCache(30*time.Second),
ldapauth.WithCacheSecret(secretFromYourSecretManager),
ldapauth.WithSealedCacheStore(redisStore{client}),   // your ~40 lines

A ByteStore is a Get, a Set, and two invalidations over []byte — which is what Redis and memcached already are. The authenticator does the serialising, seals the value under the same secret that derives the key, and verifies the seal on the way back, so a shared store is somewhere to put bytes and never a source of authorisation. That matters because a shared store is a store somebody else may be able to write to.

Two more things matter at fleet scale. Retry backoff is fully jittered, so a fleet that lost its directory at the same instant does not come back at the same instant. And FailoverInOrder, the default, sends every replica to the same server first — usually what you want, and FailoverRandom is there for when it is not.

Within one process, identical logins arriving at the same time are collapsed into a single round trip, which is the part of a stampede a cache cannot absorb: the cache only helps once the first attempt has finished.

Testing without a directory

dir := ldaptest.New(
    ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
        With("uid", "alice").
        With("mail", "alice@example.com"),
)

auth, _ := ldapauth.New(
    ldapauth.WithURL("ldap://in-memory"),
    ldapauth.WithDialer(dir.Dialer()),
    ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
)

ldaptest parses filters and applies scopes for real, and models bind semantics faithfully — including the empty-password behaviour. It is what this library's own tests run against, which is why it has tests of its own.

Metrics

Metrics is an interface of hooks, not a dependency on a metrics library. An adapter is about twenty lines:

type promMetrics struct {
    ldapauth.NoopMetrics // embed, so later interface methods do not break you

    attempts *prometheus.CounterVec
    latency  prometheus.Histogram
}

func (m *promMetrics) AuthAttempt(result string, d time.Duration) {
    m.attempts.WithLabelValues(result).Inc()
    m.latency.Observe(d.Seconds())
}

ldapauth.WithMetrics(&promMetrics{...})

The result values are a closed set, so they are safe as a label.

Performance

Against the in-memory directory, so these measure the overhead this library adds on top of a round trip rather than how fast your server is:

BenchmarkDirectBind                1542 ns/op    1034 B/op    14 allocs/op
BenchmarkSearchThenBind            4191 ns/op    2559 B/op    42 allocs/op
BenchmarkSearchThenBindWithGroups  5396 ns/op    2639 B/op    60 allocs/op
BenchmarkCachedAuthentication      1535 ns/op     952 B/op    15 allocs/op

A real directory answers a bind in single-digit milliseconds, so the library's overhead is somewhere around a tenth of a percent of a login. Which is the point of publishing these: they exist so that a regression in them is visible, not so that they can be quoted.

Examples

Every one of them runs with no LDAP server installed — they use the in-memory directory from ldaptest — except where a real URL is a flag.

Getting started

examples/basic direct bind, one login
examples/search-then-bind service account, pool, health check
examples/groups all three group sources side by side
examples/testing what a login does, printed step by step

Security

examples/escaping injection, the usual fix, and what this package does instead
examples/mtls client certificates and TLS 1.3

Serving HTTP

examples/nethttp an HTTP server that runs with no directory
examples/lookup behind an authenticating proxy, with no password
contrib/gin/example · echo · fiber the same server, per framework

Replacing a seam

examples/custom-identity your own user type, from PrincipalMapper
examples/custom-groups groups from an entitlements service, plus the directory's
examples/sealed-cache a cache shared between replicas, with the values authenticated
examples/cache-store the lower-level Cache interface, when you need your own encoding
examples/metrics a metrics adapter, with no metrics library

Operations

examples/failover a replica going away, and coming back
examples/layered rate limiter → circuit breaker → authenticator
examples/reloading rotating the service account password without a restart

Documentation

Start here

Reference

Operating it

Understanding it

Per module

Status

1.0. The exported API is stable: it will not break before 2.0, and api_test.go mirrors every exported signature so that a change to one cannot happen quietly.

What CI proves on every push: the tests pass under the race detector on Linux, macOS, and Windows, for the root module and all three contrib modules, on both the oldest toolchain the go directive allows and the newest; the root module and the Gin and Echo adapters compile for sixteen GOOS/GOARCH pairs, and the Fiber one for fourteen — fasthttp has no wasm listener; the root module still has exactly one direct dependency; every exported declaration has a doc comment and every documentation link resolves; coverage is above its floor; all eleven fuzz targets run; the workflows lint; and the integration suite passes against a real OpenLDAP over plaintext, LDAPS and StartTLS, verifying against a real certificate authority.

Every action is pinned by commit SHA, every job has a timeout and the least privilege it can do its work with, and no checkout leaves a credential behind. CodeQL and OpenSSF Scorecard run alongside.

See CHANGELOG.md.

Contributing

make check

See CONTRIBUTING.md, RELEASING.md, and CODE_OF_CONDUCT.md.

Security issues go to the advisory form, not to an issue.

License

MIT. See LICENSE.

Documentation

Overview

Package ldapauth authenticates users against an LDAP directory.

It sits between go-ldap, which speaks the protocol and leaves every decision to you, and the framework-coupled login libraries, which make every decision and bring a web framework with them. This package makes the decisions that are not really decisions — an empty password is never sent, a username is never concatenated into a filter, a pooled connection is never rebound as a user — and leaves the rest as options with defaults you can live with.

Its only dependency is github.com/go-ldap/ldap/v3. HTTP middleware lives in separate modules under contrib/, so importing this package never puts a web framework in your build graph.

Getting started

Build one authenticator at startup and share it. It is safe for concurrent use and it owns the connection pool.

auth, err := ldapauth.New(
    ldapauth.WithURL("ldaps://dc1.corp.example:636"),
    ldapauth.WithDirectBind("uid={{username}},ou=people,dc=corp,dc=example"),
)
if err != nil {
    return err
}
defer auth.Close()

identity, err := auth.Authenticate(ctx, username, password)

The error tells you which of two things happened, and that is nearly all the branching a caller needs:

switch {
case err == nil:                                       // authenticated
case errors.Is(err, ldapauth.ErrInvalidCredentials):   // 401
default:                                               // 503
}

The seams

New returns a *Client, which implements Authenticator. Everything built on top of an authenticator should depend on that interface rather than on the type, so that a test can substitute a fake and a deployment can wrap the real one.

Client.Authenticate returns an Identity, and Principal is the implementation this package produces. Most programs will never name the interface: they take what they are given and read it. The programs that do name it are the ones supplying their own user type through WithPrincipalMapper, and the reason the return is an interface at all.

Five more interfaces exist for the same reason, each with a working default, each replaceable one at a time:

Dialer           where connections come from       WithDialer
Cache            where outcomes are remembered     WithSealedCacheStore
ByteStore        the same, over bytes              WithSealedCacheStore
PrincipalMapper  what an authentication produces   WithPrincipalMapper
GroupResolver    where groups come from            WithGroupResolver
Metrics          where the numbers go              WithMetrics

None of them has to be thought about to use this package, and none of them costs a dependency.

The two strategies

A deployment binds one of two ways, and which one is available is a question about the directory, not about taste.

                 direct bind              search then bind
round trips      one                      three
needs            a DN derivable from      a service account that
                 the username             may read the user tree
set up with      WithDirectBind           WithUserSearch +
                                          WithServiceAccount

Direct bind formats the username into a DN template and binds. Use it when users live in one subtree and log in with the attribute that names their entry. It needs no privileged account, and with no service account configured it reads attributes and groups over the user's own freshly bound connection.

Search then bind has a service account find the entry first. Use it when users are spread across subtrees, or log in with something that is not part of their DN — an email address, an employee number.

What happens on a login

Authenticate
  │
  ├── reject empty username, empty password, control characters
  ├── cache lookup (off by default)
  ├── coalesce with any identical login already in flight
  ├── retry loop (retriable failures only, never a rejected password)
  │     │
  │     ├── dial: URL list, failover, TLS or StartTLS
  │     ├── bind: the user, on a connection of their own
  │     ├── read: attributes, over the pool or the user's connection
  │     └── groups: memberOf, a group search, or the AD chain rule
  │
  └── Identity

Guarantees

  • A bind is never issued with an empty password, and the request is refused before a connection is opened. RFC 4511 makes a zero-length password an unauthenticated bind, which a conforming server answers with success. go-ldap refuses to send one as well; this package refuses sooner, and with an error the caller can branch on.
  • Every value spliced into a filter is escaped for RFC 4515 — the ones that are themselves distinguished names included — and every value spliced into a DN is escaped for RFC 4514. There is no API through which an unescaped value can be interpolated.
  • A wrong password and an unknown user return the same error, so a login form built on this package cannot become an account enumeration oracle by forgetting to collapse them.
  • A pooled connection is bound as the service account and is never rebound as a user. The user's bind gets a connection of its own, which is closed afterwards.
  • A rejected credential is never retried, so this package cannot walk an account into a lockout policy.
  • Plaintext ldap:// is refused unless StartTLS is configured or WithInsecureNoTLS is passed explicitly.
  • No password is ever logged, and no attribute value is either.

Concurrency

An Authenticator is safe for concurrent use by any number of goroutines. Authenticate honours its context for the whole call, retries included, and every wait — the dial, the pool checkout, the backoff — is selectable against it. A Principal belongs to whoever received it: the authenticator keeps no reference and a cached one is served as a copy.

Close is idempotent. Calls already in flight are not interrupted; their connections are closed as they are released.

Groups

Group resolution is off by default, because it costs a round trip and not every application needs it. When you turn it on, pick the source that matches the directory:

WithMemberOfGroups          free; needs the memberOf attribute
WithGroupSearch             one search; works anywhere
WithActiveDirectoryGroups   one search, transitive; AD only

Nested groups walked client-side are bounded twice, by a configured depth and by a visited set, because directories will happily let two groups contain each other.

Caching

Caching is off by default and should stay off unless something in front of this package re-authenticates the same credentials faster than the directory can absorb — HTTP Basic auth on a chatty API being the usual reason. What is cached is the outcome, keyed by a keyed hash of the username and password; no password and nothing reversible into one is stored, and the Cache implementation never sees a credential. What it costs is a window during which a disabled account still works, and MaxCacheTTL caps that window at five minutes.

With caching on, identical logins arriving at the same time are collapsed into one round trip. The cache absorbs the second and later attempts once the first has finished; coalescing is what closes the window before that, which is the window a stampede actually lives in.

Several replicas

Nothing here is shared between processes unless you share it. Each replica has its own pool, its own failover state, and its own cache secret — which means, by default, its own cache. To share one, give every replica the same WithCacheSecret and a WithSealedCacheStore that points at the same place; without the shared secret the keys differ and the shared cache silently never hits.

WithSealedCacheStore takes a ByteStore — a Get, a Set, and two invalidations over bytes, which is what Redis and memcached already are. The authenticator serialises the identity, seals it under the same secret that derives the key, and verifies the seal on the way out, so a store somebody else can write to cannot decide who anybody is. WithCacheStore is the lower-level form for callers who need their own encoding, and leaves the value unauthenticated; see the security notes on both options.

Two things are worth knowing about running many of these at once. The retry backoff is fully jittered, so a fleet that lost its directory at the same instant does not come back at the same instant. And FailoverInOrder, the default, sends every replica to the same server first — which is usually what you want, and is what FailoverRandom exists to stop when it is not.

Testing

The ldaptest package is an in-memory directory with a real filter parser, real scopes, and faithful bind semantics — including the empty-password behaviour, so a test that asserts this package never sends one is asserting something. Point an authenticator at it with WithDialer and no server is needed.

Errors

Every error wraps a sentinel, so errors.Is reaches it whatever the detail. Failures that got as far as the protocol also carry an AuthError with the step, the server, the LDAP result code, and — when the directory said — a RejectionReason explaining why it refused.

The reason is for your log and your audit trail, never for a response body: Authenticate returns ErrInvalidCredentials for a wrong password, an unknown user, an expired password and a disabled account alike, because a caller that could tell them apart is an account enumeration oracle. ReasonFor gets it without a type assertion.

Enterprise directories

Three things that only come up on a real one, each with its own section in the documentation:

WithReferralPolicy decides what happens when a multi-domain forest answers a search with "not in this partition, try over there". Following the referral is not on offer — it would mean presenting the service account's password to a host the directory named.

WithLockoutProtection refuses an account locally after too many recent failures, so that the attempts never reach the directory's own lockout counter. Anybody who knows a username can otherwise lock that account out by getting the password wrong five times.

NewReloadable swaps the whole client, which is how the service account password is rotated without restarting the process.

Example (DirectBind)

The direct-bind strategy: the DN is derived from the username, so a login is one round trip and no privileged account is needed anywhere.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),

		// The example talks to an in-memory directory instead of the URL
		// above; a real program leaves this out.
		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	principal, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println("authentication:", err)

		return
	}

	fmt.Println(principal.GetDN())
}
Output:
uid=alice,ou=people,dc=example,dc=com
Example (ErrorHandling)

Telling the two kinds of failure apart is the whole of the error handling most callers need: a rejected credential is the user's problem, and anything else is yours.

package main

import (
	"context"
	"errors"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	_, err = auth.Authenticate(context.Background(), "alice", "wrong-password")

	switch {
	case err == nil:
		fmt.Println("authenticated")
	case errors.Is(err, ldapauth.ErrInvalidCredentials):
		fmt.Println("401")
	case errors.Is(err, ldapauth.ErrEmptyPassword):
		fmt.Println("400")
	default:
		fmt.Println("503")
	}
}
Output:
401
Example (Groups)

Group membership, read off the user's own entry.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithServiceAccount("cn=readonly,dc=example,dc=com", "service-password"),
		ldapauth.WithUserSearch("ou=people,dc=example,dc=com", "(uid={{username}})"),
		ldapauth.WithMemberOfGroups(),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	principal, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println("authentication:", err)

		return
	}

	fmt.Println(principal.GetGroups(), principal.InGroup("developers"))
}
Output:
[developers] true
Example (SearchThenBind)

The search-then-bind strategy: a service account finds the user, and the password is verified by binding as the DN the search returned.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithServiceAccount("cn=readonly,dc=example,dc=com", "service-password"),
		ldapauth.WithUserSearch(
			"ou=people,dc=example,dc=com",
			"(&(objectClass=inetOrgPerson)(uid={{username}}))",
		),
		ldapauth.WithEmailAttribute("mail"),
		ldapauth.WithDisplayNameAttribute("cn"),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	principal, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println("authentication:", err)

		return
	}

	fmt.Println(principal.GetDisplayName(), principal.GetEmail())
}
Output:
Alice Example alice@example.com

Index

Examples

Constants

View Source
const (
	// ScopeBaseObject searches the base entry only.
	ScopeBaseObject = ldap.ScopeBaseObject

	// ScopeSingleLevel searches the immediate children of the base entry.
	ScopeSingleLevel = ldap.ScopeSingleLevel

	// ScopeWholeSubtree searches the base entry and everything beneath it.
	// This is the default for both user and group searches.
	ScopeWholeSubtree = ldap.ScopeWholeSubtree
)

Search scopes, re-exported so that configuring this package does not oblige you to import go-ldap as well.

View Source
const (
	// DefaultDialTimeout bounds a single TCP connect plus TLS handshake.
	DefaultDialTimeout = 5 * time.Second

	// DefaultOperationTimeout bounds a single LDAP operation once the
	// connection is up. It exists because a server that accepts a
	// connection and then stops answering is a real failure mode, and
	// without this the only thing bounding it is the caller's context.
	DefaultOperationTimeout = 10 * time.Second

	// DefaultRequestTimeout bounds one whole call to Authenticate — dial,
	// bind, search, group resolution, and any retries — when the caller's
	// context carries no earlier deadline.
	DefaultRequestTimeout = 15 * time.Second

	// DefaultFailoverCooldown is how long a URL that failed to dial is
	// tried last rather than first.
	DefaultFailoverCooldown = 30 * time.Second

	// DefaultRetryMaxAttempts is how many *retries* a retriable failure
	// gets, over and above the first attempt.
	DefaultRetryMaxAttempts = 2

	// DefaultRetryBaseDelay is the first backoff interval.
	DefaultRetryBaseDelay = 50 * time.Millisecond

	// DefaultRetryMaxDelay caps a single backoff interval.
	DefaultRetryMaxDelay = 2 * time.Second

	// DefaultRetryMultiplier is the growth factor between retries.
	DefaultRetryMultiplier = 2.0

	// DefaultRetryMaxElapsed caps the total time spent retrying.
	DefaultRetryMaxElapsed = 10 * time.Second

	// DefaultPoolSize is the maximum number of pooled service-account
	// connections.
	DefaultPoolSize = 10

	// DefaultPoolMaxLifetime is how long a pooled connection may live
	// before it is retired, whether or not it still works. Directories
	// behind a load balancer need this: without it, connections pin
	// themselves to whichever backend was up when the process started.
	DefaultPoolMaxLifetime = 30 * time.Minute

	// DefaultPoolMaxIdleTime is how long an unused pooled connection is
	// kept before being closed.
	DefaultPoolMaxIdleTime = 5 * time.Minute

	// DefaultCacheTTL is how long a successful authentication may be
	// served from cache when caching is enabled.
	DefaultCacheTTL = 30 * time.Second

	// DefaultNegativeCacheTTL is how long a rejected authentication may be
	// served from cache. It is deliberately much shorter than the positive
	// TTL: a user who has just fixed their password should not be locked
	// out by this package's memory.
	DefaultNegativeCacheTTL = 5 * time.Second

	// MaxCacheTTL is the hard ceiling on either cache TTL. A credential
	// cache is a window during which a disabled account still works, and
	// there is no deployment for which that window should be measured in
	// hours.
	MaxCacheTTL = 5 * time.Minute

	// DefaultCacheMaxEntries bounds the cache, which is what makes it a
	// cache rather than a memory leak with good manners.
	DefaultCacheMaxEntries = 4096

	// DefaultLockoutWindow is how long failures are counted over by
	// WithLockoutProtection.
	DefaultLockoutWindow = 5 * time.Minute

	// DefaultLockoutCooldown is how long an account is refused locally
	// once the threshold is reached.
	DefaultLockoutCooldown = time.Minute

	// DefaultLockoutMaxAccounts bounds how many accounts the throttle
	// tracks. The keys come from a login form, so the bound is what stops
	// somebody inventing usernames until the process runs out of memory.
	DefaultLockoutMaxAccounts = 4096

	// DefaultUsernameMaxLength is the longest username accepted before the
	// request is rejected unread.
	DefaultUsernameMaxLength = 256

	// DefaultPasswordMaxLength is the longest password accepted before the
	// request is rejected unread. Directories store hashes of bounded
	// inputs; a megabyte arriving in a password field is not a login.
	DefaultPasswordMaxLength = 1024

	// DefaultNestedGroupDepth bounds recursive group expansion.
	DefaultNestedGroupDepth = 4

	// DefaultGroupNameAttribute is the attribute read as a group's name.
	DefaultGroupNameAttribute = "cn"

	// DefaultMemberOfAttribute is the attribute read for a user's group
	// memberships under GroupsFromMemberOf.
	DefaultMemberOfAttribute = "memberOf"
)

The defaults every option starts from. They are exported because a default you cannot name is a default you cannot reason about: a deployment that wants "the library default, but doubled" should be able to write that down.

View Source
const (
	// ResultSuccess is a completed authentication, cache or directory.
	ResultSuccess = "success"

	// ResultInvalidCredentials is a bind the directory rejected, or a user
	// the directory does not have. The two are one result on purpose; see
	// ErrInvalidCredentials.
	ResultInvalidCredentials = "invalid_credentials"

	// ResultRejected is a request this package refused before touching the
	// network: an empty password, a username with a NUL in it.
	ResultRejected = "rejected"

	// ResultThrottled is a request refused locally by
	// WithLockoutProtection, without asking the directory.
	ResultThrottled = "throttled"

	// ResultError is any other failure — the directory was unreachable,
	// the search failed, the context expired.
	ResultError = "error"
)

The values passed as the result argument to Metrics.AuthAttempt. They are a closed set so that a metrics backend can safely use them as a label value without unbounded cardinality.

View Source
const (
	CacheHit         = "hit"
	CacheMiss        = "miss"
	CacheNegativeHit = "negative_hit"

	// CacheCoalesced is a request that waited on an identical
	// authentication already in flight instead of opening one of its own.
	CacheCoalesced = "coalesced"

	// CacheError is a cache that could not answer. The authenticator
	// treats it as a miss and asks the directory.
	CacheError = "error"
)

The values passed as the event argument to Metrics.CacheEvent.

Variables

View Source
var (
	// ErrInvalidOption is returned by New when an option carries a value
	// that cannot be used, such as a negative timeout or a bind template
	// with no username placeholder. Options are validated at construction
	// rather than normalised silently, so a misconfiguration fails at
	// startup instead of on the first login attempt at 3am.
	ErrInvalidOption = errors.New("ldapauth: invalid option")

	// ErrClosed is returned by every method after Close.
	ErrClosed = errors.New("ldapauth: authenticator closed")

	// ErrEmptyUsername is returned before any network call when the
	// username is empty or is only whitespace.
	ErrEmptyUsername = errors.New("ldapauth: empty username")

	// ErrEmptyPassword is returned before any network call when the
	// password is empty.
	//
	// This is not a convenience check. RFC 4511 makes a simple bind
	// carrying a zero-length password an *unauthenticated* bind, which a
	// conforming server answers with success — so a directory that would
	// reject the wrong password accepts no password at all.
	//
	// go-ldap guards against this too, and has since v3.4: its Bind
	// refuses an empty password rather than sending one. This package
	// refuses earlier and more usefully. Earlier, because the check
	// happens before a connection is opened, so a login form being
	// hammered with empty passwords costs no handshakes. More usefully,
	// because the caller gets a sentinel to branch on rather than a
	// protocol error to parse — an empty password is a 400, not a 401.
	// And because two layers of guard against the one mistake that turns
	// an authenticator into a door is the right number.
	ErrEmptyPassword = errors.New("ldapauth: empty password")

	// ErrInvalidUsername is returned when the username contains a NUL or
	// another control character, or exceeds the configured maximum length.
	// Such a username cannot name a real directory entry; it can only be an
	// attempt to reach something else.
	ErrInvalidUsername = errors.New("ldapauth: invalid username")

	// ErrInvalidPassword is returned when the password exceeds the
	// configured maximum length. It is not "the password was wrong" —
	// that is ErrInvalidCredentials — but "this cannot be a password",
	// decided before anything reaches the wire.
	ErrInvalidPassword = errors.New("ldapauth: invalid password")

	// ErrInvalidCredentials reports that the directory rejected the bind.
	// It is returned both when the user does not exist and when the
	// password is wrong: the two are deliberately indistinguishable to the
	// caller so that an HTTP handler built on this package cannot leak an
	// account-enumeration oracle by accident. Set a logger if you need to
	// tell them apart in an operator-facing log.
	ErrInvalidCredentials = errors.New("ldapauth: invalid credentials")

	// ErrUserNotFound is returned by Lookup when the search matched no
	// entry. Authenticate never returns it; see ErrInvalidCredentials.
	ErrUserNotFound = errors.New("ldapauth: user not found")

	// ErrAmbiguousUser is returned when a user search matched more than one
	// entry. Binding as "whichever one came back first" would make the
	// identity that ends up authenticated depend on server-side ordering,
	// so the package refuses instead.
	ErrAmbiguousUser = errors.New("ldapauth: user search matched more than one entry")

	// ErrNoServerAvailable is returned when every configured URL failed to
	// dial, or when every URL is inside its failure cooldown.
	ErrNoServerAvailable = errors.New("ldapauth: no LDAP server available")

	// ErrPoolExhausted is returned when the connection pool is full and the
	// caller's context expired before a connection came free. It is a
	// backpressure signal, not a directory failure: the usual answer is a
	// larger pool or a shorter per-request deadline.
	ErrPoolExhausted = errors.New("ldapauth: connection pool exhausted")

	// ErrTLSRequired is returned by New when a plaintext ldap:// URL is
	// configured without StartTLS and without the explicit opt-out. See
	// WithInsecureNoTLS.
	ErrTLSRequired = errors.New("ldapauth: TLS required")

	// ErrNoSuchGroupAttribute is returned when the configured group
	// membership attribute is missing from an entry and strict group
	// resolution is enabled.
	ErrNoSuchGroupAttribute = errors.New("ldapauth: group membership attribute not present")

	// ErrReferral reports a search that came back with referrals and no
	// entries: the directory is saying the answer lives in another
	// partition, which on a multi-domain Active Directory forest usually
	// means the base DN names the wrong one. It is only returned under
	// ReferralFail; see WithReferralPolicy.
	ErrReferral = errors.New("ldapauth: the directory referred the search elsewhere")

	// ErrTooManyAttempts reports a username this process has refused too
	// often too recently, under WithLockoutProtection. The directory was
	// not asked, which is the point: the attempt that would have been
	// made is the attempt that would have counted against the directory's
	// own lockout policy.
	ErrTooManyAttempts = errors.New("ldapauth: too many recent failures for this account")
)

The sentinel errors the package returns. Callers should test with errors.Is rather than comparing strings; every error the package returns wraps its cause with %w, so the chain stays intact.

View Source
var ErrCacheTampered = errors.New("ldapauth: a cached value failed its integrity check")

ErrCacheTampered reports that a cached value failed its integrity check: the bytes under a key were not written by an authenticator holding this secret, or were changed after they were.

It never reaches a caller of Client.Authenticate — a value that fails to verify is a miss, and the directory is asked. It reaches the logger and Metrics.CacheEvent under CacheError, where it is worth an alert: nothing produces it by accident.

Functions

This section is empty.

Types

type AuthError

type AuthError struct {
	// Op names the step that failed, such as "bind" or "search".
	Op string

	// URL is the server the operation was talking to, if the failure
	// happened after a connection was established. Empty otherwise.
	URL string

	// Username is the account the operation concerned, or "[redacted]"
	// when WithUsernameRedaction is on.
	Username string

	// ResultCode is the LDAP result code the directory returned, or zero
	// when the failure never reached the protocol layer (a dial failure,
	// say). Compare it against the ldap.LDAPResult* constants.
	ResultCode uint16

	// Reason is why the directory refused, when the directory said. It is
	// ReasonUnspecified otherwise, which is most of the time on most
	// directories.
	//
	// Log it, count it, audit it. Do not answer with it: the whole point
	// of Authenticate returning one error for every refusal is that a
	// caller cannot accidentally build an enumeration oracle, and putting
	// the reason in a response body rebuilds one.
	Reason RejectionReason

	// Referrals are the referral URLs a search came back with, when it
	// came back with referrals and nothing else. They are the directory
	// saying "what you are looking for is not in this partition, try
	// over there"; see WithReferralPolicy.
	Referrals []string

	// Err is the wrapped cause: always a sentinel from this package,
	// possibly with the underlying *ldap.Error below it.
	Err error
}

AuthError carries the operational detail behind a failed operation: which step failed, which server it was talking to, and the LDAP result code the directory returned. It always wraps a sentinel, so

errors.Is(err, ldapauth.ErrInvalidCredentials)

keeps working whether or not the caller cares about the detail.

The password is never a field of this type, and Username is only populated when username redaction is off.

func (*AuthError) Error

func (e *AuthError) Error() string

Error renders the failure as "ldapauth: <op> for <user> on <url>: <result code>: <cause>", omitting whichever parts are not known.

func (*AuthError) Unwrap

func (e *AuthError) Unwrap() error

Unwrap returns the wrapped cause so that errors.Is and errors.As reach the sentinel and any *ldap.Error beneath it.

type Authenticator

type Authenticator interface {
	// Authenticate verifies a username and password.
	Authenticate(ctx context.Context, username, password string) (Identity, error)

	// Lookup reads an identity without verifying a password.
	Lookup(ctx context.Context, username string) (Identity, error)

	// HealthCheck reports whether the directory is reachable.
	HealthCheck(ctx context.Context) error

	// Stats reports current occupancy.
	Stats(ctx context.Context) Stats

	// InvalidateUser drops an account's cached outcomes.
	InvalidateUser(ctx context.Context, username string) (int, error)

	// InvalidateAll empties the cache.
	InvalidateAll(ctx context.Context) (int, error)

	// Close releases the pooled connections.
	Close() error
}

An Authenticator verifies credentials against a directory.

New returns a *Client, which is the implementation this package ships. This interface exists so that the things built on top of an authenticator — the middleware in httpauth, the framework adapters under contrib, and your own handlers — depend on the behaviour rather than on the type. A test can substitute a fake, and a deployment can wrap the real one in a decorator that adds a circuit breaker, a second directory, or a local account for the break-glass user, without any of them knowing.

Every method is safe for concurrent use.

Example

Depending on the interface rather than the type is what lets a deployment wrap the real authenticator — with a circuit breaker, a second directory, or a local account for the break-glass user — without anything downstream knowing.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	// Embedding the interface means a method added to Authenticator in a
	// later release passes straight through rather than breaking this.
	var wrapped ldapauth.Authenticator = breakGlass{Authenticator: auth}

	for _, username := range []string{"alice", "root"} {
		identity, err := wrapped.Authenticate(context.Background(), username, "s3cret")
		if err != nil {
			fmt.Println(username, "rejected")

			continue
		}

		fmt.Println(username, "->", identity.GetDN())
	}
}

type breakGlass struct {
	ldapauth.Authenticator
}

func (b breakGlass) Authenticate(
	ctx context.Context,
	username, password string,
) (ldapauth.Identity, error) {
	if username == "root" && password == "s3cret" {
		return &ldapauth.Principal{DN: "local:root", Username: "root", Groups: []string{"admin"}}, nil
	}

	return b.Authenticator.Authenticate(ctx, username, password)
}
Output:
alice -> uid=alice,ou=people,dc=example,dc=com
root -> local:root

type ByteStore added in v1.2.0

type ByteStore interface {
	// Get returns the bytes stored under key, if any.
	Get(ctx context.Context, key string) (value []byte, hit bool, err error)

	// Set stores value under key for ttl. The username is passed
	// unhashed so that InvalidateUser can work; see [Cache] for what
	// that means for what a store holds.
	Set(ctx context.Context, key, username string, value []byte, ttl time.Duration) error

	// InvalidateUser drops every entry for one account and reports how
	// many it dropped.
	InvalidateUser(ctx context.Context, username string) (int, error)

	// InvalidateAll drops everything and reports how many it dropped.
	InvalidateAll(ctx context.Context) (int, error)

	// Len reports how many entries are held, for Stats. Return -1 if
	// that cannot be answered cheaply.
	Len(ctx context.Context) (int, error)
}

A ByteStore is a cache that holds bytes, which is what Redis and memcached actually are.

It exists because Cache does not solve the hard half of a shared cache. Cache hands an implementation an Identity — a live Go value — and expects one back, so anybody writing a Redis store has to invent a serialisation, and anybody using WithPrincipalMapper with their own type has to invent one that round-trips it. Worse, a Cache holding decoded values trusts whatever the store returns: the key is an HMAC, but the value is not authenticated, so a directory-adjacent attacker who can write to the shared store can put an Identity carrying "cn=domain admins" under a key they cannot even read, and the next login that hits it is authorised as that. Deriving the key from a secret proves nothing about a value somebody else wrote.

WithSealedCacheStore closes both. The authenticator serialises the identity itself, seals it with HMAC-SHA-256 under the same secret that derives the key, and verifies the seal on the way back — so a store is a place to put bytes, never a source of authorisation. A value that does not verify is treated as a miss and the directory is asked.

Every method may return an error, and the authenticator treats one as a miss. Implementations must be safe for concurrent use.

type Cache

type Cache interface {
	// Get returns the cached outcome for key. A hit with a nil Identity is
	// a cached rejection.
	Get(ctx context.Context, key string) (identity Identity, hit bool, err error)

	// Set records an outcome for ttl. A nil identity records a rejection.
	// A non-positive ttl records nothing.
	Set(ctx context.Context, key, username string, identity Identity, ttl time.Duration) error

	// InvalidateUser drops every entry for one account, whatever password
	// keyed it, and reports how many it dropped.
	//
	// The in-memory implementation walks the whole cache to do it, which
	// is fine at the default bound of a few thousand entries and would
	// not be at a million. It is called when somebody's password changes,
	// not on the login path, and a credential cache large enough for the
	// walk to matter is a credential cache that has stopped being a
	// cache — but an implementation that expects to hold a great many
	// entries should index by username rather than scan.
	InvalidateUser(ctx context.Context, username string) (int, error)

	// InvalidateAll drops everything and reports how many it dropped.
	InvalidateAll(ctx context.Context) (int, error)

	// Len reports how many entries are held, for Stats. An implementation
	// that cannot answer cheaply should return -1.
	Len(ctx context.Context) (int, error)
}

A Cache remembers authentication outcomes for a short while.

The package ships an in-memory one, built by WithCache. Replace it with WithCacheStore when several replicas should share a cache — a Redis or memcached implementation is a few dozen lines against this interface.

What a Cache is given

Keys are opaque. The authenticator derives each one as an HMAC of the username and password under a secret it holds, so an implementation never sees a credential and a cache dump never yields one. By default the secret is drawn from the system CSPRNG at startup, which makes keys per-process; WithCacheSecret sets a shared one, which is what a shared cache needs in order to be shared.

The username is passed alongside the key, unhashed, so that InvalidateUser can work without knowing the password. If that is more than you want to store, hash it — the authenticator only ever passes the same string back to InvalidateUser.

Failure

Every method may return an error, and the authenticator treats one as a miss: it logs it and asks the directory. A cache that is down must never be a directory that is down.

Implementations must be safe for concurrent use.

func NewMemoryCache

func NewMemoryCache(maxEntries int, now func() time.Time) Cache

NewMemoryCache returns the in-memory cache this package uses by default, bounded to maxEntries and reading time from now.

It is exported so that a caller who wants the default behaviour with a different bound, or who is composing it with something else — a local cache in front of a shared one — does not have to write it again. Pass nil for now to use time.Now.

func NewSealedCache added in v1.2.0

func NewSealedCache(store ByteStore, secret []byte) (Cache, error)

NewSealedCache wraps a ByteStore so that values are serialised and authenticated on the way in and verified on the way out.

Most callers want WithSealedCacheStore, which builds this with the authenticator's own cache secret so the two cannot drift apart. Use this directly only when composing caches by hand.

The secret must be the same across every replica sharing the store, and it must be the same one passed to WithCacheSecret, or replicas will reject each other's entries as tampered.

Pass the master secret, not a subkey of your own: this derives the seal subkey from it, so the value given here and the value given to WithCacheSecret are the same value.

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is the Authenticator this package implements.

Build one per directory at startup and share it: it is safe for concurrent use, it owns the connection pool, and constructing one validates the whole configuration. Close it when the process is done with it.

func New

func New(opts ...Option) (*Client, error)

New builds a Client from the given options.

It validates everything it can before returning: the URLs parse, the templates have the placeholders they need, the strategy is one of the two and not both, the numbers are in range, and TLS is either configured or explicitly declined. It does not talk to the directory — a directory that is briefly down should not stop a process from starting. Use Client.HealthCheck for that.

The minimum useful configuration is a URL and a strategy:

auth, err := ldapauth.New(
    ldapauth.WithURL("ldaps://dc1.corp.example:636"),
    ldapauth.WithDirectBind("uid={{username}},ou=people,dc=corp,dc=example"),
)
Example (Production)

A production configuration, with everything the defaults leave to you.

package main

import (
	"context"
	"fmt"
	"log/slog"
	"os"
	"time"

	ldapauth "github.com/ctolon/ldap-authenticator"
)

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURLs(
			"ldaps://dc1.example.com:636",
			"ldaps://dc2.example.com:636",
		),
		ldapauth.WithRootCAFile("/etc/ssl/certs/corp-ca.pem"),
		ldapauth.WithServiceAccount(
			"cn=readonly,dc=example,dc=com",
			os.Getenv("LDAP_SERVICE_PASSWORD"),
		),
		ldapauth.WithUserSearch("ou=people,dc=example,dc=com", "(uid={{username}})"),
		ldapauth.WithMemberOfGroups(),
		ldapauth.WithEmailAttribute("mail"),

		ldapauth.WithDialTimeout(3*time.Second),
		ldapauth.WithRequestTimeout(10*time.Second),
		ldapauth.WithPoolSize(16),
		ldapauth.WithPoolMinIdle(2),
		ldapauth.WithRetry(2),
		ldapauth.WithLogger(slog.Default()),
	)
	if err != nil {
		// A configuration error here is a startup failure, and that is
		// the point: it is far better than a failed login at 3am.
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	if err := auth.HealthCheck(context.Background()); err != nil {
		fmt.Println("directory unreachable:", err)
	}
}

func (*Client) Authenticate

func (a *Client) Authenticate(ctx context.Context, username, password string) (Identity, error)

Authenticate verifies a username and password against the directory and returns who the directory says that is.

The returned error is ErrInvalidCredentials for both a wrong password and an unknown user — the two are indistinguishable to the caller on purpose, so that a login form built on this package cannot become an account enumeration oracle by forgetting to collapse them. Everything else, from a broken connection to a malformed configuration, arrives as a different error, so

if errors.Is(err, ldapauth.ErrInvalidCredentials) { ... 401 ... }
if err != nil { ... 503 ... }

is the whole of the branching a caller needs.

The context bounds the entire call, retries included. When it carries no deadline, WithRequestTimeout supplies one.

It is safe to call from many goroutines at once.

func (*Client) Close

func (a *Client) Close() error

Close releases the pooled connections. Calls already in flight are not interrupted; they finish against connections that are closed on release.

Close is idempotent, and every method afterwards returns ErrClosed — except Stats, which reports the last state, because a metrics scrape has nothing useful to do with an error while the process is shutting down.

The cache is deliberately not emptied. A shared one belongs to whoever owns it, and one replica's shutdown has no business throwing away another's entries.

func (*Client) HealthCheck

func (a *Client) HealthCheck(ctx context.Context) error

HealthCheck opens a connection, binds as the service account when one is configured, and issues a Who Am I. It reports what a login would hit without needing a real credential to hit it with.

Wire it to a readiness probe, not a liveness probe: a directory that is down is a reason to stop taking traffic, not a reason to restart.

func (*Client) InvalidateAll

func (a *Client) InvalidateAll(ctx context.Context) (int, error)

InvalidateAll empties the cache and reports how many entries it dropped.

func (*Client) InvalidateUser

func (a *Client) InvalidateUser(ctx context.Context, username string) (int, error)

InvalidateUser drops every cached entry for one account and reports how many it dropped. Call it after changing somebody's password or their group memberships, rather than waiting out the TTL. It is a no-op when caching is off.

func (*Client) Lookup

func (a *Client) Lookup(ctx context.Context, username string) (Identity, error)

Lookup reads a user's entry and groups without verifying a password.

It is for the case where authentication happened elsewhere — a SAML assertion, a signed token, an upstream proxy — and the directory is still where the identity's attributes and groups live. It needs a service account, because there is no user bind to borrow a connection from.

Unlike Authenticate, Lookup distinguishes a missing user: it returns ErrUserNotFound. There is no oracle to protect here, since the caller already knows who they are asking about.

Example

Reading an identity without a password, for when authentication happened somewhere else — a SAML assertion, an OIDC token, an authenticating proxy. Unlike Authenticate, this distinguishes a missing user: the caller already knows who they are asking about.

package main

import (
	"context"
	"errors"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithServiceAccount("cn=readonly,dc=example,dc=com", "service-password"),
		ldapauth.WithUserSearch("ou=people,dc=example,dc=com", "(uid={{username}})"),
		ldapauth.WithEmailAttribute("mail"),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	identity, err := auth.Lookup(context.Background(), "alice")
	if err != nil {
		fmt.Println("lookup:", err)

		return
	}

	fmt.Println(identity.GetEmail())

	if _, err := auth.Lookup(context.Background(), "nobody"); errors.Is(err, ldapauth.ErrUserNotFound) {
		fmt.Println("nobody is not in the directory")
	}
}
Output:
alice@example.com
nobody is not in the directory

func (*Client) Stats

func (a *Client) Stats(ctx context.Context) Stats

Stats reports the authenticator's current occupancy. It is a snapshot for a metrics scrape or a debug endpoint; nothing in the package branches on it.

The context is there for the cache, which may live somewhere else. A cache that cannot answer leaves CacheEntries at -1 rather than failing the call: a debug endpoint should not go down because Redis did.

Stats is the one method that still answers after Close. It reports the last state rather than an error, because the caller is a metrics scrape or a debug handler and neither of them has anything useful to do with ErrClosed while the process is on its way out.

type Conn

type Conn interface {
	// Bind performs a simple bind. An empty password is an
	// unauthenticated bind and this package never issues one; see
	// ErrEmptyPassword.
	Bind(username, password string) error

	// Search performs a search operation.
	Search(request *ldap.SearchRequest) (*ldap.SearchResult, error)

	// SearchWithPaging performs a search operation with a paged results
	// control, for directories that cap the number of entries a single
	// search may return.
	SearchWithPaging(request *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error)

	// WhoAmI issues the RFC 4532 "Who am I?" extended operation. The pool
	// uses it as a liveness probe.
	WhoAmI(controls []ldap.Control) (*ldap.WhoAmIResult, error)

	// SetTimeout bounds how long a single operation may take.
	SetTimeout(timeout time.Duration)

	// IsClosing reports whether the connection is shutting down.
	IsClosing() bool

	// Close releases the connection.
	Close() error
}

Conn is the slice of *ldap.Conn this package actually uses.

The package talks to the directory only through this interface. That is what makes every layer above the wire testable without a server, and it keeps the package insulated from methods being added to go-ldap's own much larger Client interface. *ldap.Conn satisfies it as it stands.

type ControlBinder added in v1.1.0

type ControlBinder interface {
	// SimpleBind performs a simple bind and returns the response
	// controls, which is where a directory other than Active Directory
	// says why it refused.
	SimpleBind(request *ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error)
}

A ControlBinder is a Conn that can also return the controls a bind produced.

It is an optional interface rather than a method on Conn, because Conn is part of the 1.x API and adding a method to it would break every implementation that already exists. *ldap.Conn satisfies this one; a custom transport may, and if it does not, the only thing lost is the password policy control.

type Dialer

type Dialer interface {
	// DialContext opens a connection to url and returns it ready for a
	// bind: for an ldaps:// URL the TLS handshake has completed, and for
	// an ldap:// URL with StartTLS configured the upgrade has happened.
	//
	// Implementations must honour ctx for the duration of the dial and
	// must not retain it afterwards.
	DialContext(ctx context.Context, url string) (Conn, error)
}

Dialer opens connections to the directory.

The package ships one, built from the TLS and timeout options, and uses it unless WithDialer replaces it. Replacing it is the supported way to reach a directory this package cannot dial on its own — a connection arriving over an SSH tunnel or a service mesh sidecar, say — and it is how the package's own tests drive failure paths without a server.

type FailoverPolicy

type FailoverPolicy int

FailoverPolicy decides the order in which configured servers are tried.

const (
	// FailoverInOrder tries the configured URLs in the order they were
	// given, always starting from the first healthy one. This is the
	// default, and it is what you want when the list is ordered by
	// preference — a local replica first, a remote one behind it.
	FailoverInOrder FailoverPolicy = iota

	// FailoverRoundRobin starts each attempt at the next URL in sequence,
	// spreading load across the healthy servers. Use it when the servers
	// are equivalent and you would rather not send every bind in the fleet
	// to the same replica.
	FailoverRoundRobin

	// FailoverRandom starts each attempt at a uniformly random URL. Like
	// round robin, but without the shared counter — worth having when many
	// processes share one server list and starting them in lockstep would
	// synchronise their round robins.
	FailoverRandom
)

func (FailoverPolicy) String

func (p FailoverPolicy) String() string

String returns a stable, lower-case name for the policy, suitable for a log field or a metrics label.

type GroupRequest

type GroupRequest struct {
	// Username is the username as supplied to Authenticate.
	Username string

	// DN is the distinguished name that was authenticated.
	DN string

	// Entry is the user's directory entry, or nil when the configuration
	// read none. The resolver must not retain it.
	Entry *ldap.Entry

	// Search runs searches against the directory. It is valid only for the
	// duration of the call.
	Search Searcher
}

GroupRequest is what a GroupResolver is asked about.

type GroupResolver

type GroupResolver interface {
	// ResolveGroups returns the groups the request's identity belongs to,
	// as names or as DNs — whichever the rest of the application expects.
	// Returning an empty slice and a nil error means "no groups", which
	// is a legitimate answer; returning an error fails the
	// authentication.
	ResolveGroups(ctx context.Context, request GroupRequest) ([]string, error)
}

A GroupResolver produces the groups an authenticated identity belongs to.

The default resolves them from the directory, in whichever of the three ways the configuration asked for. Replace it when membership lives somewhere the directory is not — an entitlements service, a mapping table, a static list for a break-glass account — or when your directory has a group scheme this package does not model.

It runs inside the caller's context and inside the retry loop, so it should be quick and it must not have side effects that would be wrong to repeat.

Failure is closed, and that is a decision

An error fails the whole authentication, even though the password was already verified. The alternative — returning the identity with no groups — hands the application a principal that looks like a real login by a user who belongs to nothing, and every RequireAnyGroup check reads that as "not authorised" while every check that only asks whether somebody is logged in reads it as "yes". A group server having a bad afternoon would quietly demote everybody rather than lock them out, and a demotion is much harder to notice than an outage.

So the package will not guess. If your deployment would rather degrade than stop — groups are advisory, and the directory is the thing that must stay up — say so explicitly by wrapping the default:

fallible := ldapauth.GroupResolverFunc(
	func(ctx context.Context, request ldapauth.GroupRequest) ([]string, error) {
		groups, err := inner.ResolveGroups(ctx, request)
		if err != nil {
			logger.Error("group resolution failed; logging in with none", "err", err)

			return nil, nil
		}

		return groups, nil
	},
)

Written out like that, the trade is visible in the code that made it, which is where it belongs.

type GroupResolverFunc

type GroupResolverFunc func(ctx context.Context, request GroupRequest) ([]string, error)

GroupResolverFunc adapts a function to GroupResolver.

func (GroupResolverFunc) ResolveGroups

func (f GroupResolverFunc) ResolveGroups(ctx context.Context, request GroupRequest) ([]string, error)

ResolveGroups calls f.

type GroupSource

type GroupSource int

GroupSource selects how a Principal's groups are discovered.

const (
	// GroupsDisabled resolves no groups. Principal.Groups is always empty
	// and no group search is issued. This is the default: group resolution
	// costs a round trip and not every application needs it.
	GroupsDisabled GroupSource = iota

	// GroupsFromMemberOf reads the memberships off the user's own entry,
	// from the attribute named by WithMemberOfAttribute. It costs no extra
	// round trip and it is what Active Directory and any OpenLDAP with the
	// memberof overlay will give you.
	GroupsFromMemberOf

	// GroupsFromSearch searches the group tree for entries that name the
	// user as a member. Use it on directories with no memberOf overlay, or
	// where group membership lives outside the user's entry.
	GroupsFromSearch
)

func (GroupSource) String

func (g GroupSource) String() string

String returns a stable, lower-case name for the source, suitable for a log field or a metrics label.

type Identity

type Identity interface {
	// GetDN returns the distinguished name of the entry that was
	// authenticated.
	GetDN() string

	// GetUsername returns the username, as supplied or as read from the
	// attribute named by WithUsernameAttribute.
	GetUsername() string

	// GetDisplayName returns a human-readable name, or the empty string.
	GetDisplayName() string

	// GetEmail returns an email address, or the empty string.
	GetEmail() string

	// GetGroups returns the resolved group names, or DNs under
	// WithGroupDNs. The slice belongs to the caller.
	GetGroups() []string

	// GetAttribute returns the first value of an attribute, or the empty
	// string.
	GetAttribute(name string) string

	// GetAttributeValues returns every value of an attribute, or nil.
	GetAttributeValues(name string) []string

	// InGroup reports membership, compared case-insensitively.
	InGroup(name string) bool

	// InAnyGroup reports membership of at least one of the named groups.
	// With no names it must report false.
	InAnyGroup(names ...string) bool

	// InAllGroups reports membership of every named group. With no names
	// it must report true.
	InAllGroups(names ...string) bool

	// Clone returns a deep copy.
	Clone() Identity
}

Identity is what an authentication produced: whoever the directory says the caller is.

Client.Authenticate and Client.Lookup return one of these rather than a concrete type, so that a deployment whose notion of a user is richer than this package's can supply its own — see WithPrincipalMapper. Principal is the implementation this package ships, and is what you get unless you ask for something else; most programs will never name this interface at all.

The accessors are spelled Get* because Principal carries the same information as exported fields, and a field and a method cannot share a name. It is the spelling go-ldap itself uses for ldap.Entry.

An implementation must be safe for concurrent reads, and Clone must return a value that shares nothing mutable with the original: the cache stores one Identity and hands out copies, and a caller who edits what they were given must not be able to edit what the next caller gets.

type Mapping

type Mapping struct {
	// Username is the username as supplied to Authenticate.
	Username string

	// DN is the distinguished name that was authenticated.
	DN string

	// Entry is the directory entry, or nil when the configuration read
	// none. The mapper must not retain it.
	Entry *ldap.Entry

	// Groups are the resolved groups, already expanded and labelled.
	Groups []string
}

Mapping is what a PrincipalMapper is given: everything the authentication established, before it becomes an Identity.

type Metrics

type Metrics interface {
	// AuthAttempt reports one completed call to Authenticate, with one of
	// the Result constants and how long it took.
	AuthAttempt(result string, d time.Duration)

	// CacheEvent reports one cache interaction, with one of the Cache
	// constants.
	CacheEvent(event string)

	// RetryScheduled reports that an operation failed with a retriable
	// error and will be tried again after delay.
	RetryScheduled(op string, attempt int, delay time.Duration)

	// ConnOpened reports a new connection to the directory.
	ConnOpened(url string)

	// ConnClosed reports a connection being discarded, with a short reason
	// such as "idle", "lifetime", "unhealthy", or "error".
	ConnClosed(reason string)

	// PoolStats reports the pool's occupancy after a checkout or return.
	PoolStats(inUse, idle int)
}

Metrics receives counters and timings from the authenticator.

It is an interface of hooks rather than a dependency on a metrics library, which is what lets this package keep its one-dependency promise. A Prometheus, OpenTelemetry, or statsd adapter is a few dozen lines on the caller's side; the README has one.

Every method is called on the goroutine that did the work, so an implementation that blocks blocks an authentication. Do the cheap thing here and buffer if you must.

Stability

This interface is frozen for the 1.x line. No method will be added to it, because adding one would break every implementation that does not embed NoopMetrics — and "embed this and hope" is not a compatibility promise, it is a request. Anything worth counting later arrives as a separate optional interface that an implementation may also satisfy, discovered by type assertion.

Embedding NoopMetrics is still worth doing: it saves writing the methods you do not care about.

type NoGroups

type NoGroups struct{}

NoGroups is a GroupResolver that resolves nothing. It is what the package uses when group resolution is disabled, and it is a useful base for a resolver that only sometimes has an answer.

func (NoGroups) ResolveGroups

func (NoGroups) ResolveGroups(context.Context, GroupRequest) ([]string, error)

ResolveGroups returns no groups and no error.

type NoopMetrics

type NoopMetrics struct{}

NoopMetrics implements Metrics and does nothing. It is the default, and it is what implementations should embed so that methods added to Metrics in a later release do not break them.

func (NoopMetrics) AuthAttempt

func (NoopMetrics) AuthAttempt(string, time.Duration)

AuthAttempt does nothing.

func (NoopMetrics) CacheEvent

func (NoopMetrics) CacheEvent(string)

CacheEvent does nothing.

func (NoopMetrics) ConnClosed

func (NoopMetrics) ConnClosed(string)

ConnClosed does nothing.

func (NoopMetrics) ConnOpened

func (NoopMetrics) ConnOpened(string)

ConnOpened does nothing.

func (NoopMetrics) PoolStats

func (NoopMetrics) PoolStats(int, int)

PoolStats does nothing.

func (NoopMetrics) RetryScheduled

func (NoopMetrics) RetryScheduled(string, int, time.Duration)

RetryScheduled does nothing.

type Option

type Option func(*config) error

An Option configures the authenticator. Options are applied in the order they are given and are validated as they are applied, so New reports a bad value with the name of the option that carried it rather than as a mysterious failure on the first login.

Every option that takes a duration treats a negative value as an error and zero as "no limit", except where the doc comment says otherwise.

func WithActiveDirectoryGroups

func WithActiveDirectoryGroups(baseDN string) Option

WithActiveDirectoryGroups configures group resolution the way Active Directory wants it: a search under baseDN using the LDAP_MATCHING_RULE_IN_CHAIN matching rule, OID 1.2.840.113556.1.4.1941, which has the server compute transitive group membership itself.

ldapauth.WithActiveDirectoryGroups("OU=Groups,DC=corp,DC=example")

One search returns every group the user is in, directly or through nesting, so this is both faster and more complete than walking the membership graph from the client. It is worth reaching for whenever the directory is AD.

The matching rule is an Active Directory extension. A directory that does not implement it answers the search with no entries rather than with an error, which is indistinguishable from a user who is in no groups — so do not enable this against anything else.

func WithAnonymousServiceBind

func WithAnonymousServiceBind() Option

WithAnonymousServiceBind searches without binding at all, for the rare directory that answers anonymous searches. It has to be asked for explicitly because a directory that permits anonymous search and a deployment that forgot its service account credentials look identical from inside this package.

func WithAttributes

func WithAttributes(names ...string) Option

WithAttributes asks for further attributes, which arrive in Principal.Attributes exactly as the directory returned them. Calling it more than once appends.

Ask only for what you use. Every attribute named here is transferred on every login, and an attribute you fetch is an attribute you may accidentally log.

func WithCache

func WithCache(ttl time.Duration) Option

WithCache serves repeat authentications from memory for ttl.

Caching is off by default and should stay off unless something in front of this package re-authenticates the same credentials at a rate the directory cannot absorb — HTTP Basic auth on a chatty API being the usual reason.

What is cached is the *result*, keyed by a keyed hash of the username and password; neither the password nor a reversible function of it is stored. What it costs is a window during which a disabled account, a changed password, or a revoked group still works. MaxCacheTTL caps that window at five minutes, and there is no option to raise the cap.

func WithCacheSecret

func WithCacheSecret(secret []byte) Option

WithCacheSecret sets the key that turns a credential into a cache key, so that several replicas derive the same key for the same login.

Without it the secret is drawn from the system CSPRNG when the authenticator is built, which is what you want for a single process: the key is unrelated between runs and worthless outside the one that made it. Set this only when a cache is genuinely shared, and treat the value as a secret — anybody holding it can test a guessed credential against the cache offline.

Use 32 bytes — 256 bits, the size this package generates when you do not supply one. Sixteen is the hard floor for compatibility, and a secret below 32 is logged as a warning at startup.

Treat it the way you treat the service account password, and not as the same value: it is a different secret protecting a different thing, and reusing one for both means a compromise of either is a compromise of both. Rotate it on the same schedule, and remember that rotating it invalidates the shared cache rather than corrupting it — every key changes at once, so the worst that happens is a cold cache.

func WithCacheSize

func WithCacheSize(entries int) Option

WithCacheSize bounds the number of cached entries. When the bound is reached the oldest entries are dropped.

func WithCacheStore

func WithCacheStore(store Cache) Option

WithCacheStore replaces the in-memory cache with an implementation of your own, which is how several replicas share one.

ldapauth.WithCache(30*time.Second),
ldapauth.WithCacheSecret(secretFromYourSecretManager),
ldapauth.WithCacheStore(redisCache{client}),

A shared cache needs a shared secret as well. Keys are derived from the credential by HMAC under the authenticator's secret, and the default secret is drawn from the system CSPRNG at startup — so without WithCacheSecret every replica derives a different key for the same login and the shared cache never hits.

The store never sees a credential: it is handed an opaque key, the username, and the outcome. What it must not do is outlive the trust boundary the outcome was decided in — a cache shared between two services is a cache in which one service's authentication decisions become the other's.

Read the write side before you use this

A Cache is handed a decoded Identity and trusted for what it returns. The key is an HMAC, so nobody can read an entry without the secret, but nothing authenticates the value: anybody who can write to the store can put an identity carrying "cn=domain admins" under a key of their choosing, and the login that lands on it is authorised as that. This is not a theoretical asymmetry — a Redis with no auth on a flat network is the normal way it happens.

Prefer WithSealedCacheStore, which authenticates the value as well as the key. Use this option when you need control over the encoding, or when WithPrincipalMapper gives you an identity this package cannot serialise; then treat write access to the store as equivalent to write access to the directory, and say so in your threat model.

Caching must be turned on with WithCache as well; a store with no TTL configured is a configuration error rather than an implicit default.

func WithCacheUsernameHashing added in v1.1.0

func WithCacheUsernameHashing() Option

WithCacheUsernameHashing replaces the username the cache store sees with a keyed hash of it, under the same secret as the key.

The key already hides the credential, but the username travels alongside it in the clear, because Client.InvalidateUser has to find an account's entries without knowing the password. On a store that is shared, remote, or backed up, that username is personal data this package put there. With this, the store holds nothing anybody can read, and invalidation still works because it hashes the same way.

What it costs is the ability to look in the cache and see who is in it, which is a real debugging affordance — which is why this is a choice rather than the default.

func WithCaseFoldUsername

func WithCaseFoldUsername() Option

WithCaseFoldUsername lower-cases the username before it is used as a cache key, so that "Alice" and "alice" share one entry on a directory that matches case-insensitively — which is most of them.

It changes the cache key only. The username sent to the directory is always the one the caller supplied, because deciding how a username compares is the directory's job and its matching rules are not knowable from here.

func WithClientCertificate

func WithClientCertificate(cert tls.Certificate) Option

WithClientCertificate presents a client certificate to the directory, for deployments that authenticate the application itself with mutual TLS.

func WithClientCertificateFiles

func WithClientCertificateFiles(certFile, keyFile string) Option

WithClientCertificateFiles loads a PEM certificate and key from disk and presents them to the directory. The files are read once, when the option is applied; rotating them means building a new authenticator, or setting GetClientCertificate through WithTLSConfig.

func WithClock

func WithClock(now func() time.Time) Option

WithClock replaces the package's source of time.

It exists for tests: cache expiry, failover cooldowns, and connection lifetimes are all decided against this clock, and driving them from a test is otherwise a matter of sleeping and hoping. The function must be safe to call from several goroutines at once.

func WithDerefAliases

func WithDerefAliases(deref int) Option

WithDerefAliases sets alias dereferencing for searches, as one of ldap.NeverDerefAliases, ldap.DerefInSearching, ldap.DerefFindingBaseObj, or ldap.DerefAlways. The default is never: an alias that can redirect a user search is an alias that can redirect an authentication.

func WithDialTimeout

func WithDialTimeout(d time.Duration) Option

WithDialTimeout bounds one TCP connect plus TLS handshake. Zero is an error here: a dial with no timeout is a goroutine with no end.

func WithDialer

func WithDialer(d Dialer) Option

WithDialer replaces the connection factory.

The dialer this package builds from its own options handles TLS, StartTLS, and timeouts. Replace it when the connection has to come from somewhere this package cannot reach on its own, and in tests. A custom dialer takes over responsibility for TLS: the TLS options are not applied to it, and the plaintext check in New is skipped, because there is no way for this package to know what your dialer negotiated.

func WithDirectBind

func WithDirectBind(dnTemplate string) Option

WithDirectBind configures the direct-bind strategy: the username is substituted into a DN template and bound directly, with no service account and no search.

ldapauth.WithDirectBind("uid={{username}},ou=people,dc=corp,dc=example")
ldapauth.WithDirectBind("{{username}}@corp.example")   // Active Directory UPN

The substituted value is escaped as an RFC 4514 attribute value, so a username containing a comma names an entry rather than a new DN component. A single %s is accepted in place of {{username}}, which is the spelling most existing LDAP configuration uses.

Direct bind is one round trip and needs no privileged account, which makes it the strategy to prefer when the DN can be derived from the username. When it cannot — because users live in several subtrees, or log in with an attribute that is not part of their DN — use WithUserSearch.

Attributes and groups still need somewhere to be read from. By default they are read over the user's own freshly bound connection, so a direct bind deployment needs no service account at all; configure WithServiceAccount as well if the users themselves may not read their own entry.

func WithDisplayNameAttribute

func WithDisplayNameAttribute(name string) Option

WithDisplayNameAttribute names the attribute read into Principal.DisplayName, commonly "displayName" or "cn".

func WithEmailAttribute

func WithEmailAttribute(name string) Option

WithEmailAttribute names the attribute read into Principal.Email, commonly "mail" or "userPrincipalName".

func WithFailoverCooldown

func WithFailoverCooldown(d time.Duration) Option

WithFailoverCooldown sets how long a server that failed to dial is tried last. Zero disables the memory, so every login pays the timeout of every dead server ahead of the live one.

func WithFailoverPolicy

func WithFailoverPolicy(p FailoverPolicy) Option

WithFailoverPolicy sets the order in which several servers are tried.

func WithGroupDNs

func WithGroupDNs() Option

WithGroupDNs reports group DNs in Principal.Groups instead of names.

DNs are unambiguous and names are not: two subtrees may each hold a group called "admins", and an authorisation rule written against the name cannot tell them apart. Prefer DNs when the group tree is large enough for that to be possible.

func WithGroupNameAttribute

func WithGroupNameAttribute(name string) Option

WithGroupNameAttribute names the attribute read as a group's name, "cn" by default. It is also what nested resolution reads when it walks from a group DN to a group name.

func WithGroupResolver

func WithGroupResolver(resolver GroupResolver) Option

WithGroupResolver replaces group resolution entirely.

The default resolves groups from the directory in whichever of the three ways the group options asked for. Replace it when membership lives somewhere the directory is not, or when your directory has a group scheme this package does not model. The resolver is handed a Searcher so that it can still ask the directory, on the authenticator's connection and with the authenticator's identity.

It replaces the built-in resolution rather than adding to it: the group source options are not consulted, so a resolver that wants both should compose the directory's answer with its own.

Example

A custom resolver takes groups from somewhere the directory is not. It replaces the built-in resolution rather than adding to it, so one that wants both should ask the directory itself through ldapauth.GroupRequest.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),

		ldapauth.WithGroupResolver(ldapauth.GroupResolverFunc(
			func(_ context.Context, request ldapauth.GroupRequest) ([]string, error) {
				return rolesFor(request.DN), nil
			},
		)),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	identity, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println("authentication:", err)

		return
	}

	fmt.Println(identity.GetGroups())
}

func rolesFor(string) []string { return []string{"billing:read", "billing:write"} }
Output:
[billing:read billing:write]

func WithGroupSearch

func WithGroupSearch(baseDN, filterTemplate string) Option

WithGroupSearch resolves groups by searching baseDN for entries that name the user as a member.

ldapauth.WithGroupSearch(
    "ou=groups,dc=corp,dc=example",
    "(&(objectClass=groupOfNames)(member={{user_dn}}))",
)

The template may use {{user_dn}}, {{username}}, and {{base_dn}}. {{user_dn}} expands to the DN of the authenticated entry. Every placeholder is escaped for RFC 4515, the DNs included: a DN in a filter is an assertion value, and escaping it leaves its commas and equals signs alone while stopping an asterisk in it from widening the match.

Use this on directories with no memberOf overlay, or where memberships live on the group rather than on the user — which is the case for posixGroup and for groupOfNames without the overlay.

func WithGroupSearchScope

func WithGroupSearchScope(scope int) Option

WithGroupSearchScope sets the scope of the group search. The default is the whole subtree.

func WithInsecureNoTLS

func WithInsecureNoTLS() Option

WithInsecureNoTLS permits a plaintext ldap:// connection with no StartTLS upgrade.

A simple bind sends the password as a length-prefixed string with no encoding at all. Without this option New refuses such a configuration, and the option is named the way it is so that the refusal cannot be silenced without leaving evidence in the source.

func WithInsecureSkipVerify

func WithInsecureSkipVerify() Option

WithInsecureSkipVerify accepts any certificate the directory presents.

This turns TLS into encryption without authentication: an attacker who can answer for the directory's address can present their own certificate, take the bind, and read the password. Use it to get a self-signed lab running, and use WithRootCAFile everywhere else.

func WithLockoutProtection added in v1.1.0

func WithLockoutProtection(attempts int, window, cooldown time.Duration) Option

WithLockoutProtection refuses an account locally after too many recent failures, so that the attempts never reach the directory's own lockout counter.

ldapauth.WithLockoutProtection(4, 5*time.Minute, time.Minute)

The threat is not brute force. A directory that locks an account after five failures is a directory where anybody who knows a username can lock that account out from anywhere, by getting the password wrong five times — no access needed, and the victim is the one who calls the help desk. This package already refuses to make it worse by retrying a rejected credential; this is what stops it being the instrument.

Attempts served from the cache do not count and are not blocked, so a user who logged in recently keeps working while somebody guesses at their username. Without a cache there is nothing to serve them from, and they are refused along with the attacker — which is a local cooldown of a minute rather than a directory lockout an administrator has to clear, but it is a real cost and it is why this is not on by default.

Two limits worth knowing. It counts failures per account, not requests per client, so it is not a rate limiter — put one in front. And the counters are per process, so twenty replicas pass twenty times the threshold through before all of them are blocking. Making that exact would mean a round trip to somewhere shared on every failed login, paid on the attack path to make the attack slightly harder.

Zero attempts turns it off, which is the default.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger attaches a *slog.Logger.

Logging is optional and log/slog is in the standard library, so this costs no dependency. The package logs stages, outcomes, and errors. It logs the username, or "[redacted]" under WithUsernameRedaction. It never logs a password, a service account password, or any attribute value.

func WithMemberOfAttribute

func WithMemberOfAttribute(name string) Option

WithMemberOfAttribute names the attribute holding group memberships on the user's entry, and selects the memberOf group source.

func WithMemberOfGroups

func WithMemberOfGroups() Option

WithMemberOfGroups resolves groups from the user's own entry, reading the attribute named by WithMemberOfAttribute ("memberOf" by default).

This is the cheap way: the memberships come back with the user's attributes and cost no extra round trip. It needs a directory that maintains the attribute — Active Directory always does, OpenLDAP does with the memberof overlay configured.

func WithMetrics

func WithMetrics(m Metrics) Option

WithMetrics attaches a Metrics implementation. Embed NoopMetrics in it so that methods added to the interface later do not break your build.

func WithMinTLSVersion

func WithMinTLSVersion(version uint16) Option

WithMinTLSVersion sets the minimum TLS version accepted, as one of the tls.VersionTLS* constants. The default is TLS 1.2.

func WithNegativeCache

func WithNegativeCache(ttl time.Duration) Option

WithNegativeCache serves repeat *failures* from memory for ttl, which is what stops a misconfigured client retrying a stale password from reaching the directory often enough to lock the account out. The default is DefaultNegativeCacheTTL, deliberately much shorter than the positive TTL. Zero disables negative caching while leaving positive caching on.

func WithNestedGroupDepth

func WithNestedGroupDepth(depth int) Option

WithNestedGroupDepth caps how far nested expansion walks. The default is DefaultNestedGroupDepth. The cap is what stops a directory with a membership cycle from turning one login into an unbounded walk; the expansion also remembers what it has already visited, so the cap is a second line of defence rather than the only one.

func WithNestedGroups

func WithNestedGroups() Option

WithNestedGroups expands groups that are members of other groups, up to WithNestedGroupDepth levels.

It costs round trips: one lookup per group under the memberOf source, one search per level under the search source. On Active Directory prefer WithActiveDirectoryNestedGroups, which asks the server to do the whole expansion in a single search.

func WithOperationTimeout

func WithOperationTimeout(d time.Duration) Option

WithOperationTimeout bounds a single LDAP operation on an established connection. Zero means no per-operation limit, leaving only the request timeout and the caller's context.

func WithPagingSize

func WithPagingSize(size uint32) Option

WithPagingSize turns searches into paged searches with the given page size. Active Directory caps a search at 1000 entries and truncates silently past that, so a group search over a large directory needs this to see the whole answer. Zero, the default, does not page.

func WithPasswordMaxLength

func WithPasswordMaxLength(n int) Option

WithPasswordMaxLength rejects passwords longer than n before any network call. The default is DefaultPasswordMaxLength.

It is not about password strength. It is about not carrying a megabyte from a request body to a directory, once per request, for as long as somebody cares to send one.

func WithPasswordPolicyControl added in v1.1.0

func WithPasswordPolicyControl() Option

WithPasswordPolicyControl asks for the password policy control on every bind, which is how a directory other than Active Directory says *why* it refused a credential.

With it, AuthError.Reason can distinguish an expired password, a locked account, or one that must be changed after a reset, on OpenLDAP, 389 Directory Server and anything else implementing draft-behera-ldap-password-policy. Active Directory needs nothing here: it puts the same information in the error text, and this package reads it either way.

It is off by default because it changes what goes on the wire. The control is non-critical, so a directory that does not know it ignores it, but "adds an unrequested control to every bind" is not a thing to do to somebody without asking.

The reason never changes what Client.Authenticate returns. It is for the log line and the audit trail; see RejectionReason.

func WithPoolHealthCheck

func WithPoolHealthCheck(enabled bool) Option

WithPoolHealthCheck controls whether a pooled connection is probed before being handed out. The probe is a Who Am I extended operation, which is a round trip; without it, the first search on a connection the server has quietly dropped fails instead, and is retried on a fresh one.

The default is on. Turn it off when the round trip matters more than the retry, which is to say when the directory is local and the request rate is high.

func WithPoolMaxIdleTime

func WithPoolMaxIdleTime(d time.Duration) Option

WithPoolMaxIdleTime closes a pooled connection that has gone unused for this long. Zero keeps idle connections indefinitely, up to the maximum lifetime.

func WithPoolMaxLifetime

func WithPoolMaxLifetime(d time.Duration) Option

WithPoolMaxLifetime retires a pooled connection after this long, however healthy it is. Zero disables retirement.

Keep it well under any idle timeout the directory or a load balancer in front of it enforces, and keep it finite: a connection that never retires never notices that the load balancer has new backends.

func WithPoolMinIdle

func WithPoolMinIdle(n int) Option

WithPoolMinIdle keeps at least n connections open and bound, so that a burst after a quiet period does not pay for n binds. The default is zero: connections are opened on demand and closed when they go idle.

func WithPoolSize

func WithPoolSize(n int) Option

WithPoolSize caps the pooled service-account connections. The pool only exists when the configuration searches the directory at all; a direct bind with no lookups never builds one.

Size this against the directory's own connection limit, not against your request rate: pooled connections are held for the duration of a search, which is milliseconds.

func WithPrincipalMapper

func WithPrincipalMapper(mapper PrincipalMapper) Option

WithPrincipalMapper replaces the mapping from a completed authentication to an Identity.

Use it to produce your application's own user type, or to enrich the Principal this package ships with something the directory does not know. The mapper runs inside the caller's context and inside the retry loop, so it should be quick and it must be safe to run twice.

Mapping.Entry is the directory entry, and it is nil unless the configuration asked for something to be read from it. A mapper that wants an attribute has to say so with WithAttributes, the same as anything else — the entry is not fetched speculatively, because a round trip nobody needs is a round trip on every login.

ldapauth.WithPrincipalMapper(ldapauth.PrincipalMapperFunc(
    func(m ldapauth.Mapping) (ldapauth.Identity, error) {
        return &user{dn: m.DN, name: m.Username, groups: m.Groups}, nil
    },
))
Example

A custom mapper produces the application's own user type. Everything downstream keeps working because it depends on ldapauth.Identity rather than on the concrete type.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
		ldapauth.WithAttributes("mail"),

		ldapauth.WithPrincipalMapper(ldapauth.PrincipalMapperFunc(
			func(m ldapauth.Mapping) (ldapauth.Identity, error) {
				// Principal.Extra is for the common case: a type of your
				// own is only needed when you want your own methods.
				return &ldapauth.Principal{
					DN:       m.DN,
					Username: m.Username,
					Groups:   m.Groups,
					Extra:    tenantFor(m.Username),
				}, nil
			},
		)),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	identity, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println("authentication:", err)

		return
	}

	principal, _ := ldapauth.AsPrincipal(identity)

	fmt.Println(principal.Username, principal.Extra)
}

func tenantFor(string) string { return "acme" }
Output:
alice acme

func WithReferralPolicy added in v1.1.0

func WithReferralPolicy(policy ReferralPolicy) Option

WithReferralPolicy decides what happens when a search comes back with referrals and no entries.

A referral is the directory saying "not in this partition, try over there". On a single-domain deployment it never happens; on a multi-domain Active Directory forest it happens whenever a base DN names a partition that does not hold the object — which is a configuration mistake that otherwise presents as "no such user", and sends somebody hunting for an account that is exactly where it should be.

ReferralIgnore, the default, treats the answer as empty and logs a warning. ReferralFail returns ErrReferral with the URLs on the AuthError, which is what to use once you are confident your base DNs are right.

Neither follows the referral, and that is a decision rather than an omission: following one means opening a connection to a host the directory named and presenting the service account's password to it. Use the Global Catalog for a forest-wide search instead — see docs/directories.md.

func WithRequestTimeout

func WithRequestTimeout(d time.Duration) Option

WithRequestTimeout bounds one whole call to Authenticate or Lookup, retries included. It applies only when the caller's context has no earlier deadline of its own — a caller who passes a deadline always gets the tighter of the two. Zero disables it.

func WithRetry

func WithRetry(attempts int) Option

WithRetry sets how many times a *retriable* failure is tried again.

Retriable means the failure said nothing about the answer: the connection broke, the server was busy, the server was going down. Rejected credentials are never retried — a directory with a lockout policy counts failed binds, and a library that retried them would turn one typo into three strikes.

func WithRetryBackoff

func WithRetryBackoff(base, max time.Duration, multiplier float64) Option

WithRetryBackoff sets the retry delay envelope: the first delay, the cap on a single delay, and the growth factor between them. The actual delay is drawn uniformly from (0, envelope] — full jitter, because a fleet that lost its directory at the same instant should not come back at the same instant.

func WithRetryMaxElapsed

func WithRetryMaxElapsed(d time.Duration) Option

WithRetryMaxElapsed caps the total time spent retrying, backoff included. A retry that would sleep past the cap is not taken. Zero removes the cap, leaving only the attempt count.

func WithRootCAFile

func WithRootCAFile(path string) Option

WithRootCAFile reads one or more PEM certificates from path and uses them to verify the directory's certificate, replacing the system pool. The file is read once, when the option is applied.

func WithRootCAs

func WithRootCAs(pool *x509.CertPool) Option

WithRootCAs sets the certificate pool used to verify the directory's certificate, replacing the system pool.

func WithSealedCacheStore added in v1.2.0

func WithSealedCacheStore(store ByteStore) Option

WithSealedCacheStore shares a cache across replicas without trusting what is in it.

ldapauth.WithCache(30*time.Second),
ldapauth.WithCacheSecret(secretFromYourSecretManager),
ldapauth.WithSealedCacheStore(redisStore{client}),

A ByteStore holds bytes, which is what Redis and memcached are. The authenticator serialises the identity, seals it with HMAC-SHA-256 under the same secret that derives the key, and verifies the seal on the way back out. A value that does not verify is a miss: it is logged, counted by Metrics.CacheEvent under CacheError, and the directory is asked. So a store can lose entries, or be written to by somebody who should not be, without ever being able to decide who somebody is.

This is the option to reach for. WithCacheStore leaves the value unauthenticated and exists for callers who need their own encoding.

Two things it does not carry:

  • Principal.Extra is dropped. It holds a value of your type, and JSON would hand it back as a map — so a cached login would differ from an uncached one in a way that only shows up in production.
  • A custom Identity from WithPrincipalMapper cannot be encoded at all. The write fails, is logged and counted, and the login proceeds uncached. Use WithCacheStore with your own serialisation instead.

The same WithCacheSecret must reach every replica, both for the keys to match and for the seals to verify. Rotating it invalidates the shared cache rather than corrupting it: every key changes, and any entry found under an old key fails its seal and is treated as a miss.

The secret is never used directly. Key derivation, sealing, and username hashing each take their own HKDF subkey, because sharing one made the login path a signing oracle for the seal — see derive.go and the security notes. An entry also carries its own expiry inside the MAC, so a store that ignores its TTL cannot replay an old identity: integrity proves who wrote a value, not when.

Caching must be turned on with WithCache as well.

func WithSearchSizeLimit

func WithSearchSizeLimit(limit int) Option

WithSearchSizeLimit caps how many entries a search may return. Zero, the default, leaves the limit to the server.

func WithServerName

func WithServerName(name string) Option

WithServerName sets the name verified against the directory's certificate. Set it when connecting by IP address, or through a name that is not the one on the certificate.

func WithServiceAccount

func WithServiceAccount(dn, password string) Option

WithServiceAccount sets the credentials used for searching: for the user search under WithUserSearch, and for group and attribute lookups under either strategy.

The account needs read access to the user and group subtrees and nothing more. It never needs to write, and it never needs to read passwords — this package verifies a password by binding, never by comparing.

func WithStartTLS

func WithStartTLS() Option

WithStartTLS upgrades plaintext connections with the RFC 4513 StartTLS extended operation before any bind. It is ignored for ldaps:// URLs, which are wrapped from the first byte, and for ldapi:// unix sockets.

A StartTLS that the server refuses is a hard failure: the connection is closed rather than used unencrypted. There is no downgrade path, by design.

func WithTLSConfig

func WithTLSConfig(t *tls.Config) Option

WithTLSConfig sets the TLS configuration wholesale, replacing anything the narrower TLS options had set. The value is cloned, so later modifications by the caller do not reach the authenticator.

func WithURL

func WithURL(url string) Option

WithURL sets the directory URL. Calling it more than once appends, so it composes with WithURLs; see WithURLs for what more than one URL means.

ldapauth.WithURL("ldaps://dc1.corp.example:636")

func WithURLs

func WithURLs(urls ...string) Option

WithURLs sets several directory URLs, tried in the order given until one accepts a connection. A URL that fails to dial is moved to the back of the queue for WithFailoverCooldown, so a replica that is down costs one connection timeout rather than one per login.

Failover is between servers holding the same data. It is not a way to authenticate against several unrelated directories: whichever server answers, the same base DNs and filters are used against it.

func WithUserSearch

func WithUserSearch(baseDN, filterTemplate string) Option

WithUserSearch configures the search-then-bind strategy: a service account searches baseDN for the entry matching filterTemplate, and the user's password is then verified by binding as the DN that search returned.

ldapauth.WithUserSearch(
    "ou=people,dc=corp,dc=example",
    "(&(objectClass=inetOrgPerson)(uid={{username}}))",
)

The substituted username is escaped for RFC 4515, so a filter cannot be broken out of. {{base_dn}} is also available, and expands to baseDN.

The user bind happens on its own connection, which is closed afterwards and never returned to the pool. Rebinding a pooled connection as the user would leave that connection carrying the user's identity for whichever request picked it up next — the single most common way to build an authorisation bypass on top of LDAP.

A search that matches more than one entry is an error, not a race to pick the first: see ErrAmbiguousUser.

func WithUserSearchScope

func WithUserSearchScope(scope int) Option

WithUserSearchScope sets the scope of the user search: ScopeBaseObject, ScopeSingleLevel, or ScopeWholeSubtree. The default is the whole subtree; narrowing it to a single level is worth doing when the directory is flat, because it stops a filter typo from walking the tree.

func WithUsernameAttribute

func WithUsernameAttribute(name string) Option

WithUsernameAttribute names the attribute read into Principal.Username. Without it, Principal.Username is the username as supplied by the caller — which is usually what you want, and is the only thing available under a direct bind that does no lookup.

func WithUsernameMaxLength

func WithUsernameMaxLength(n int) Option

WithUsernameMaxLength rejects usernames longer than n before any network call. The default is DefaultUsernameMaxLength.

func WithUsernameRedaction

func WithUsernameRedaction() Option

WithUsernameRedaction replaces the username with "[redacted]" in log records and in AuthError. Set it where the logs are less trusted than the directory — a shipped log aggregator, a support bundle.

func WithoutRetry

func WithoutRetry() Option

WithoutRetry disables retries. Every failure is reported to the caller as it happened.

type Principal

type Principal struct {
	// DN is the distinguished name of the entry that was authenticated.
	// Under a direct bind with no lookup it is the expanded bind template;
	// otherwise it is the DN the directory returned.
	DN string

	// Username is the username as supplied to Authenticate, unless
	// WithUsernameAttribute named an attribute to read it from.
	Username string

	// DisplayName is the value of the attribute named by
	// WithDisplayNameAttribute, or empty.
	DisplayName string

	// Email is the value of the attribute named by WithEmailAttribute, or
	// empty.
	Email string

	// Groups holds the resolved group names, or group DNs under
	// WithGroupDNs. It is empty when group resolution is disabled, which
	// is the default. Order follows the directory and is not meaningful.
	Groups []string

	// Attributes holds every attribute the search asked for, exactly as
	// the directory returned it. It is nil when nothing was fetched.
	Attributes map[string][]string

	// Extra is untouched by this package. It is somewhere for a custom
	// PrincipalMapper to put whatever it looked up alongside the
	// directory entry — a tenant, a role from a database, a feature flag —
	// without having to implement Identity from scratch.
	//
	// Clone copies the field, not what it points at. An Extra that is
	// mutable is shared between clones, so put something immutable in it.
	Extra any
}

Principal is the Identity this package produces by default.

It is a plain struct with exported fields, so it can be constructed in a test or a fake without going through the directory, and it satisfies Identity, so it can be returned from a custom PrincipalMapper that only wants to adjust the mapping rather than replace the type.

A Principal is owned by whoever received it: the authenticator keeps no reference, and one served from cache is a copy. Mutate it freely.

func AsPrincipal

func AsPrincipal(identity Identity) (*Principal, bool)

AsPrincipal recovers the concrete Principal behind an Identity, for callers who have not replaced the mapper and would rather read fields than call accessors.

if p, ok := ldapauth.AsPrincipal(identity); ok {
    log.Println(p.DN, p.Attributes)
}

func (*Principal) Clone

func (p *Principal) Clone() Identity

Clone returns a deep copy. The cache stores one Principal and hands out another, so that a caller who edits what they were given cannot edit what the next caller will be given.

func (*Principal) GetAttribute

func (p *Principal) GetAttribute(name string) string

GetAttribute returns the first value of the named attribute, or the empty string. The lookup is case-insensitive, because directories are inconsistent about the case of attribute names in results.

func (*Principal) GetAttributeValues

func (p *Principal) GetAttributeValues(name string) []string

GetAttributeValues returns every value of the named attribute, or nil.

func (*Principal) GetDN

func (p *Principal) GetDN() string

GetDN returns Principal.DN.

func (*Principal) GetDisplayName

func (p *Principal) GetDisplayName() string

GetDisplayName returns Principal.DisplayName.

func (*Principal) GetEmail

func (p *Principal) GetEmail() string

GetEmail returns Principal.Email.

func (*Principal) GetGroups

func (p *Principal) GetGroups() []string

GetGroups returns a copy, so that a caller ranging over it cannot be surprised by an Identity implementation that shares its slice.

func (*Principal) GetUsername

func (p *Principal) GetUsername() string

GetUsername returns Principal.Username.

func (*Principal) InAllGroups

func (p *Principal) InAllGroups(names ...string) bool

InAllGroups reports whether the principal is in every named group. An empty list reports true, which is the vacuous reading and the useful one: "no group is required" should not reject anybody.

func (*Principal) InAnyGroup

func (p *Principal) InAnyGroup(names ...string) bool

InAnyGroup reports whether the principal is in at least one of the named groups. An empty list reports false: "in any of nothing" is not membership, and returning true would turn an unset configuration into an open door.

func (*Principal) InGroup

func (p *Principal) InGroup(name string) bool

InGroup reports whether the principal is in the named group.

The comparison is case-insensitive. LDAP directories match group names and DNs case-insensitively, so a case-sensitive check here would be a check that passes in testing and fails against the directory that capitalises differently.

type PrincipalMapper

type PrincipalMapper interface {
	// MapPrincipal turns a completed authentication into an Identity.
	// Returning a nil Identity with a nil error is an error in itself:
	// there is no such thing as a successful authentication with nobody
	// behind it.
	MapPrincipal(mapping Mapping) (Identity, error)
}

A PrincipalMapper turns a completed authentication into an Identity.

Replacing it is the way to make this package produce your application's own user type, or to enrich the one it ships with information from somewhere else. The default reads the attributes named by WithEmailAttribute and friends into a Principal.

It runs on the goroutine handling the request, inside the caller's context and inside the retry loop, so it should be quick and it must not have side effects that would be wrong to repeat. An error it returns fails the authentication.

type PrincipalMapperFunc

type PrincipalMapperFunc func(mapping Mapping) (Identity, error)

PrincipalMapperFunc adapts a function to PrincipalMapper.

func (PrincipalMapperFunc) MapPrincipal

func (f PrincipalMapperFunc) MapPrincipal(mapping Mapping) (Identity, error)

MapPrincipal calls f.

type ReferralPolicy added in v1.1.0

type ReferralPolicy int

A ReferralPolicy decides what to do about a search the directory answered with referrals instead of entries.

A referral is the directory saying "what you are looking for is not in this partition; try over there". On a single-domain deployment it never happens. On a multi-domain Active Directory forest it happens whenever a base DN names a partition that does not hold the object — which is a configuration mistake that otherwise presents as "that user does not exist", and is the single most confusing way for an AD deployment to fail.

const (
	// ReferralIgnore treats a referral-only answer as an empty answer,
	// and logs a warning saying that is what happened. It is the default,
	// because it is what every version of this package has done and
	// because a referral alongside entries is normal on AD.
	ReferralIgnore ReferralPolicy = iota

	// ReferralFail turns a referral-only answer into ErrReferral, with
	// the URLs on the AuthError. Use it when you know your base DNs are
	// right, so that the day they stop being right you are told rather
	// than left wondering why a user vanished.
	ReferralFail
)

func (ReferralPolicy) String added in v1.1.0

func (p ReferralPolicy) String() string

String returns a stable, lower-case name for the policy, suitable for a log field or a metrics label.

type RejectionReason added in v1.1.0

type RejectionReason uint8

A RejectionReason is why the directory refused a credential, when the directory said.

Client.Authenticate returns ErrInvalidCredentials for every one of these, and that is deliberate: a login form that could tell "wrong password" from "no such user" is an account enumeration oracle, and one that could tell "password expired" from either is a slower one. The reason is for the other audience — the operator reading a log, the auditor asking why logins from one department all failed last Tuesday — and it reaches them through AuthError.Reason rather than through anything a caller might put in a response body.

Where it comes from:

Active Directory   the data code in the bind error, always available
OpenLDAP and 389   the password policy control, with WithPasswordPolicyControl
anything else      ReasonUnspecified

A reason is a hint, not a guarantee. Directories are inconsistent about which of these they distinguish, several of them deliberately blur the distinction, and a directory that says nothing is not saying the password was right.

const (
	// ReasonUnspecified is a refusal the directory did not explain.
	ReasonUnspecified RejectionReason = iota

	// ReasonBadPassword is the account existing and the password not
	// matching.
	ReasonBadPassword

	// ReasonNoSuchUser is the account not existing. Note that most
	// directories report it as a bad password on purpose.
	ReasonNoSuchUser

	// ReasonAccountDisabled is an account an administrator has switched
	// off.
	ReasonAccountDisabled

	// ReasonAccountLocked is an account the directory's own lockout
	// policy has locked, usually after failed attempts. See
	// WithLockoutProtection.
	ReasonAccountLocked

	// ReasonAccountExpired is an account past its expiry date.
	ReasonAccountExpired

	// ReasonPasswordExpired is a valid password that is too old to use.
	ReasonPasswordExpired

	// ReasonPasswordMustChange is a password that must be reset before
	// the account can be used, typically after an administrator reset it.
	ReasonPasswordMustChange

	// ReasonTimeRestricted is an account not permitted to log in at this
	// hour.
	ReasonTimeRestricted

	// ReasonWorkstationRestricted is an account not permitted to log in
	// from this machine.
	ReasonWorkstationRestricted
)

func ReasonFor added in v1.1.0

func ReasonFor(err error) RejectionReason

ReasonFor extracts the RejectionReason from an error, or ReasonUnspecified if there is none.

if ldapauth.ReasonFor(err) == ldapauth.ReasonPasswordExpired {
    audit.Record(username, "password expired")
}

The response you send the user should not depend on this. What it is for is the log line, the audit trail, and the metric label.

Example

Why the directory refused, for the audit trail — while the caller still gets the same answer whatever the reason.

package main

import (
	"context"
	"errors"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	auth, err := ldapauth.New(
		ldapauth.WithURL("ldaps://dc1.example.com:636"),
		ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),

		// On OpenLDAP and 389 Directory Server this is what makes an
		// expired password distinguishable. Active Directory needs
		// nothing: it says so in the error text either way.
		ldapauth.WithPasswordPolicyControl(),

		ldapauth.WithDialer(exampleDirectory().Dialer()),
	)
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}
	defer auth.Close()

	_, err = auth.Authenticate(context.Background(), "alice", "wrong-password")

	reason := ldapauth.ReasonFor(err)

	// Record the reason. Answer with the error.
	fmt.Println("audit:", reason, "actionable:", reason.Actionable())
	fmt.Println("response:", errors.Is(err, ldapauth.ErrInvalidCredentials))
}
Output:
audit: unspecified actionable: true
response: true

func (RejectionReason) Actionable added in v1.1.0

func (r RejectionReason) Actionable() bool

Actionable reports whether the reason is one the user could do something about by trying different credentials.

A bad password is: try again. An expired password, a disabled account, or a time restriction is not — the user can type all day and it will keep failing. It is the distinction worth surfacing to a help desk, and the one worth counting separately in a dashboard, because a rise in unactionable refusals is an operational event rather than an attack.

func (RejectionReason) String added in v1.1.0

func (r RejectionReason) String() string

String returns a stable, lower-case name for the reason, suitable for a log field or a metrics label. The set is closed, so it is safe as a label value.

type Reloadable added in v1.1.0

type Reloadable struct {
	// contains filtered or unexported fields
}

A Reloadable is an Authenticator whose underlying Client can be replaced while it is in use.

The service account password is read once, when the Client is built, and there is deliberately no way to change it underneath a running one — a field that can change while requests read it is a field that needs a lock, and the lock would be on the hot path of every login to serve a rotation that happens twice a year.

The answer is to build a new Client and swap the pointer, which is what this is. It exists because everybody writes it, and because the part people get wrong is the ordering: the old Client must be closed *after* the swap, never before, or requests already choosing it find it closed.

reloadable := ldapauth.NewReloadable(client)

// ... later, when the secret manager says the password changed:
next, err := ldapauth.New(optionsWith(newPassword)...)
if err != nil {
    return err            // keep serving with the old one
}

previous := reloadable.Replace(next)
previous.Close()

Closing the old Client does not interrupt requests already running on it; they finish against connections that are closed as they are released. What it does stop is new requests, and by the time Close is called there are none, because the swap already happened.

A Reloadable is safe for concurrent use, and reading the current Client costs an atomic load — which is to say it costs nothing worth measuring.

func NewReloadable added in v1.1.0

func NewReloadable(client *Client) *Reloadable

NewReloadable wraps a Client. It panics on a nil Client, because a Reloadable with nothing in it has no useful behaviour and would fail on the first request instead of at startup.

Example

Rotating the service account password without restarting the process.

package main

import (
	"context"
	"fmt"

	ldapauth "github.com/ctolon/ldap-authenticator"
	"github.com/ctolon/ldap-authenticator/ldaptest"
)

// exampleDirectory is the in-memory directory the examples run against, so
// that they are compiled and executed rather than merely displayed.
func exampleDirectory() *ldaptest.Directory {
	return ldaptest.New(
		ldaptest.User("uid=alice,ou=people,dc=example,dc=com", "s3cret").
			With("uid", "alice").
			With("cn", "Alice Example").
			With("mail", "alice@example.com").
			With("objectClass", "inetOrgPerson").
			With("memberOf", "cn=developers,ou=groups,dc=example,dc=com"),

		ldaptest.User("cn=readonly,dc=example,dc=com", "service-password").
			With("objectClass", "person"),

		ldaptest.Group("cn=developers,ou=groups,dc=example,dc=com").
			With("cn", "developers").
			With("objectClass", "groupOfNames").
			With("member", "uid=alice,ou=people,dc=example,dc=com"),
	)
}

func main() {
	dir := exampleDirectory()

	build := func() (*ldapauth.Client, error) {
		return ldapauth.New(
			ldapauth.WithURL("ldaps://dc1.example.com:636"),
			ldapauth.WithDirectBind("uid={{username}},ou=people,dc=example,dc=com"),
			ldapauth.WithDialer(dir.Dialer()),
		)
	}

	client, err := build()
	if err != nil {
		fmt.Println("configuration:", err)

		return
	}

	auth := ldapauth.NewReloadable(client)
	defer auth.Close()

	// When the secret manager says the password changed: build, check,
	// swap, then close what was replaced. Never the other way round.
	next, err := build()
	if err != nil {
		fmt.Println("reload failed, keeping the old client:", err)

		return
	}

	previous := auth.Replace(next)

	if err := previous.Close(); err != nil {
		fmt.Println("closing the replaced client:", err)
	}

	identity, err := auth.Authenticate(context.Background(), "alice", "s3cret")
	if err != nil {
		fmt.Println("authentication:", err)

		return
	}

	fmt.Println(identity.GetUsername())
}
Output:
alice

func (*Reloadable) Authenticate added in v1.1.0

func (r *Reloadable) Authenticate(ctx context.Context, username, password string) (Identity, error)

Authenticate verifies a username and password against the current Client.

func (*Reloadable) Close added in v1.1.0

func (r *Reloadable) Close() error

Close closes the current Client. It does not close any Client that was previously replaced: Replace handed those back, and closing them was the caller's to do.

func (*Reloadable) Current added in v1.1.0

func (r *Reloadable) Current() *Client

Current returns the Client in use, for the calls this interface does not cover.

func (*Reloadable) HealthCheck added in v1.1.0

func (r *Reloadable) HealthCheck(ctx context.Context) error

HealthCheck probes the current Client's directory.

func (*Reloadable) InvalidateAll added in v1.1.0

func (r *Reloadable) InvalidateAll(ctx context.Context) (int, error)

InvalidateAll empties the current Client's cache.

func (*Reloadable) InvalidateUser added in v1.1.0

func (r *Reloadable) InvalidateUser(ctx context.Context, username string) (int, error)

InvalidateUser drops an account's cached outcomes from the current Client.

func (*Reloadable) Lookup added in v1.1.0

func (r *Reloadable) Lookup(ctx context.Context, username string) (Identity, error)

Lookup reads an identity from the current Client.

func (*Reloadable) Replace added in v1.1.0

func (r *Reloadable) Replace(next *Client) (previous *Client)

Replace swaps in a new Client and returns the one it replaced, for the caller to close.

The previous Client is returned rather than closed here, because when to close it is the caller's decision: immediately is right for a password rotation, and after a grace period is right if something outside this package still holds a reference. It is never nil.

Replace panics on a nil Client. Reloading is a thing that goes wrong — the secret manager was unreachable, the new password is not live yet — and the right answer to a failed reload is to keep serving with the Client you have, not to install nothing.

What the new Client does not inherit

It inherits nothing. A Client's state lives in the Client, so the replacement starts with an empty cache, an empty throttle, and — unless WithCacheSecret was given — a freshly generated cache secret, which makes every existing key underivable.

Rotating a service-account password every hour therefore means an empty cache every hour, and a throttle whose count of recent failures resets with it: an attacker who can time a rotation gets WithLockoutProtection attempts again on the other side of it. Neither is a correctness problem, and both are a surprise if you have not thought about them.

Build the replacement from the same option list as the original, with only the credential changed, and carry WithCacheSecret and WithCacheStore or WithSealedCacheStore across every rotation — a shared store plus a fixed secret means the cache survives, since the entries were never in the process. The throttle is per-process either way; if rotations are frequent enough for the reset to matter, rate limit in front, as examples/layered shows.

func (*Reloadable) Stats added in v1.1.0

func (r *Reloadable) Stats(ctx context.Context) Stats

Stats reports the current Client's occupancy.

type Searcher

type Searcher interface {
	// Search runs one search and returns its result. The context bounds
	// the call, and the request must not be retained or mutated
	// afterwards.
	Search(ctx context.Context, request *ldap.SearchRequest) (*ldap.SearchResult, error)
}

A Searcher runs a search against the directory on the authenticator's behalf.

It is what a GroupResolver is handed instead of a connection: the authenticator decides which identity the search runs as — the pooled service account where there is one, the user's own connection otherwise — applies the configured paging, and keeps the connection's lifetime to itself. A resolver cannot bind, cannot write, and cannot hold the connection past the call it was given.

type SearcherFunc

type SearcherFunc func(ctx context.Context, request *ldap.SearchRequest) (*ldap.SearchResult, error)

SearcherFunc adapts a function to Searcher.

func (SearcherFunc) Search

func (f SearcherFunc) Search(ctx context.Context, request *ldap.SearchRequest) (*ldap.SearchResult, error)

Search calls f.

type Stats

type Stats struct {
	// Servers is how many URLs are configured.
	Servers int

	// HealthyServers is how many of them are not currently inside a
	// failure cooldown. A number below Servers means failover has been
	// used recently; a zero means every server has failed and the next
	// attempt will retry them all.
	HealthyServers int

	// PoolSize is the configured maximum, or zero when there is no pool.
	PoolSize int

	// PoolInUse is how many pooled connections are checked out.
	PoolInUse int

	// PoolIdle is how many are open and waiting.
	PoolIdle int

	// BlockedAccounts is how many accounts are inside a local lockout
	// cooldown, or zero when WithLockoutProtection is off. A number that
	// climbs is either an attack on those accounts or a client with a
	// stale credential; both are worth a look.
	BlockedAccounts int

	// CacheEntries is how many outcomes are cached, or zero when caching
	// is off. It counts cached failures as well as cached successes.
	CacheEntries int
}

Stats is a snapshot of an authenticator's occupancy, as returned by Stats. It is plain data: read it, publish it, and do not branch on it — the numbers are already stale by the time you have them.

Directories

Path Synopsis
contrib
echo module
fiber module
gin module
examples
basic command
Command basic authenticates one username and password with the direct-bind strategy, which needs no service account.
Command basic authenticates one username and password with the direct-bind strategy, which needs no service account.
cache-store command
Command cache-store implements the Cache interface, the lower-level of the two ways several replicas share one cache.
Command cache-store implements the Cache interface, the lower-level of the two ways several replicas share one cache.
custom-groups command
Command custom-groups resolves group membership from somewhere the directory is not, and shows how to combine that with what the directory does know.
Command custom-groups resolves group membership from somewhere the directory is not, and shows how to combine that with what the directory does know.
custom-identity command
Command custom-identity replaces the identity this package produces with the application's own user type.
Command custom-identity replaces the identity this package produces with the application's own user type.
escaping command
Command escaping shows what LDAP injection looks like, what the usual fix looks like, and what this package does instead.
Command escaping shows what LDAP injection looks like, what the usual fix looks like, and what this package does instead.
failover command
Command failover shows what happens when a directory replica goes away.
Command failover shows what happens when a directory replica goes away.
groups command
Command groups shows the three ways group membership can be resolved, against whichever the directory supports.
Command groups shows the three ways group membership can be resolved, against whichever the directory supports.
layered command
Command layered shows the shape a high-traffic deployment wants, and which parts of it this library is and is not.
Command layered shows the shape a high-traffic deployment wants, and which parts of it this library is and is not.
lookup command
Command lookup reads a user's attributes and groups without a password, for the case where authentication already happened somewhere else.
Command lookup reads a user's attributes and groups without a password, for the case where authentication already happened somewhere else.
metrics command
Command metrics implements the Metrics interface, which is how numbers leave this package without a metrics library entering it.
Command metrics implements the Metrics interface, which is how numbers leave this package without a metrics library entering it.
mtls command
Command mtls connects to a directory that authenticates the application itself with a client certificate, on top of authenticating the user with a bind.
Command mtls connects to a directory that authenticates the application itself with a client certificate, on top of authenticating the user with a bind.
nethttp command
Command nethttp is an HTTP server that authenticates against LDAP, using the httpauth package.
Command nethttp is an HTTP server that authenticates against LDAP, using the httpauth package.
reloading command
Command reloading rotates the service account password without restarting the process.
Command reloading rotates the service account password without restarting the process.
sealed-cache command
Command sealed-cache shares a cache between replicas without trusting what is in it.
Command sealed-cache shares a cache between replicas without trusting what is in it.
search-then-bind command
Command search-then-bind authenticates with a service account: it searches for the user's entry, then verifies the password by binding as the DN the search returned.
Command search-then-bind authenticates with a service account: it searches for the user's entry, then verifies the password by binding as the DN the search returned.
testing command
Command testing runs the whole library against the in-memory directory in the ldaptest package, so you can see what a login does without having an LDAP server anywhere.
Command testing runs the whole library against the in-memory directory in the ldaptest package, so you can see what a login does without having an LDAP server anywhere.
Package httpauth turns an ldapauth.Authenticator into HTTP authentication: middleware for net/http, a login handler, and the policy core that the Gin, Echo, and Fiber modules under contrib/ are built on.
Package httpauth turns an ldapauth.Authenticator into HTTP authentication: middleware for net/http, a login handler, and the policy core that the Gin, Echo, and Fiber modules under contrib/ are built on.
internal
backoff
Package backoff computes retry delays.
Package backoff computes retry delays.
tools/doccheck command
Command doccheck reports exported API with no doc comment.
Command doccheck reports exported API with no doc comment.
tools/testca command
Command testca writes a throwaway CA and server certificate for the integration directory.
Command testca writes a throwaway CA and server certificate for the integration directory.
Package ldaptest is an in-memory LDAP directory for tests.
Package ldaptest is an in-memory LDAP directory for tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL