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.
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
},
))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.
go get github.com/ctolon/ldap-authenticatorAnd, 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/fibernet/http needs no extra module: httpauth is in the root module, because
it needs nothing outside the standard library. See
docs/http.md.
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 UPNSearch 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.
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.
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 onlyif 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.
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.
- 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
WithInsecureNoTLSis passed explicitly. - No password is logged, ever, and none is stored: the cache key is an
HMAC under a random key, and a
Cacheimplementation never sees a credential. - This package will not walk an account into a lockout. A rejected
credential is never retried, and
WithLockoutProtectionstops 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.
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 linesA 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.
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 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.
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.
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 |
Start here
- docs/strategies.md — choosing and configuring a bind strategy
- docs/groups.md — the three sources, nesting, and what each costs
- docs/http.md — middleware, login handlers, status codes, sessions
- docs/directories.md — what to configure for AD, OpenLDAP, 389ds, FreeIPA
Reference
- docs/configuration.md — every option, its default, and the thing worth knowing
- docs/extending.md — the interfaces, and how to replace one
- docs/testing.md — the in-memory directory, the integration suite, the fuzz targets
Operating it
- docs/production.md — timeouts, pools, failover, replicas, and what to alert on
- docs/troubleshooting.md — the errors you will actually see
Understanding it
- docs/design.md — how the layers fit, and what each one refuses to do
- docs/security.md — the invariants, and the attacks behind them
Per module
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.
make checkSee CONTRIBUTING.md, RELEASING.md, and CODE_OF_CONDUCT.md.
Security issues go to the advisory form, not to an issue.
MIT. See LICENSE.