# `Macaw.Auth.Native.Source.Ldap`
[🔗](https://github.com/dcoai/macaw_auth/blob/v0.4.1/lib/macaw/auth/native/source/ldap.ex#L1)

A directory as a sign-in source, over OTP's `:eldap` (no external LDAP
library). Ported from exio's, the copy with the security fixes, plus
macaw's `allow_plaintext: false`. The configuration is
`Macaw.Auth.Native.Source.Ldap.Config`'s:

    %{type: :ldap, name: "nas", host: "nas.example.test", port: 389,
      ssl: false, starttls: true, cacertfile: "/etc/ssl/nas-ca.pem",
      user_dn_template: "uid={username},cn=users,dc=example,dc=test",
      groups: %{base_dn: "cn=groups,dc=example,dc=test", member_attr: "memberUid"},
      required_group: "macaw", role_mapping: %{"macaw-admin" => "admin"},
      default_role: "viewer"}

The flow, each failure its own reason (`explain/1`), for the log and
`mix macaw_auth.ldap.check`, never for the visitor:

1. An empty password is refused before connecting (`:empty_password`):
   LDAP treats it as an anonymous bind, which "succeeds".
2. Connect: LDAPS, or StartTLS, verifying the certificate chain (`depth:
   3`) and the hostname, with SNI, unless `verify_peer: false`
   (`:connect_failed`, `:tls_failed`). Without either, only with
   `allow_plaintext: true` (`:plaintext_refused`).
3. Find the user's DN: the template with the username RFC 4514-escaped
   (`Macaw.Auth.Native.Source.Ldap.Escape`), or a search whose filter is
   built with the username as data (`Macaw.Auth.Native.Source.Ldap.Filter`)
   (`:search_failed`, `:user_not_found`, `:ambiguous_user`).
4. Bind as that DN with the password (`:invalid_credentials`).
5. Read the attributes, then the groups (`:group_search_failed`).
6. Apply `required_group` (`:not_in_group`) and derive the roles
   (`:no_role`); the directory's username must be a valid one
   (`:invalid_username`).

Every value sent to `:eldap` (DN, password, filter value) goes as its
UTF-8 bytes, so a non-ASCII password binds as the directory stored it.
Passwords are never logged; a directory that can't be reached or
searched is logged as a warning, naming the source.

**Whether it knows the user** (`knows?/2`): a directory that wasn't
reached, or found no such entry, doesn't, and the chain moves on. A
direct-bind source can't tell a wrong password from a missing entry (both
fail the bind), so its `:invalid_credentials` passes on too; in search
mode the entry was found, so a failed bind is final.

# `reason`

```elixir
@type reason() ::
  :empty_password
  | :invalid_credentials
  | :plaintext_refused
  | :connect_failed
  | :tls_failed
  | :search_failed
  | :user_not_found
  | :ambiguous_user
  | :group_search_failed
  | :not_in_group
  | :no_role
  | :invalid_username
```

# `t`

```elixir
@type t() :: %Macaw.Auth.Native.Source.Ldap{
  allow_plaintext: term(),
  bind_password: term(),
  cacertfile: term(),
  default_role: term(),
  display_name_attr: term(),
  email_attr: term(),
  groups: term(),
  host: term(),
  id_attr: term(),
  name: term(),
  port: term(),
  required_group: term(),
  role_mapping: term(),
  search: term(),
  ssl: term(),
  starttls: term(),
  timeout_ms: term(),
  user_dn_template: term(),
  username_attr: term(),
  verify_peer: term()
}
```

# `diagnose`

```elixir
@spec diagnose(t(), String.t(), String.t() | nil) :: [
  {String.t(), :ok | :error, String.t()}
]
```

Runs the sign-in flow for `username` without signing in, and says how
each step went: `[{step, :ok | :error, detail}]`, ending at the first
failure. The steps: connect, TLS, find the user's DN (the search), bind,
read attributes, username, groups, required group, roles. The password
is used for the bind and appears nowhere in the result.

# `member?`

```elixir
@spec member?([%{dn: String.t(), cn: String.t() | nil}], String.t()) :: boolean()
```

Whether `groups` include `name`, a CN or a DN, ignoring case.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
