> ## Documentation Index
> Fetch the complete documentation index at: https://docs.strata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# LDAP

LDAP service extensions let you customize how the Orchestrator handles directory operations when running as an LDAP provider. Use these hooks to implement custom search logic, authenticate users against external systems, or support Windows integrated authentication (NTLM via GSSAPI/SPNEGO).

## Request Lifecycle

The LDAP provider operates over TCP using the LDAP protocol (not HTTP). Each operation type has its own processing flow and hook point.

### Search

```mermaid theme={null}
%%{init: {'theme': 'neutral', 'themeVariables': {'edgeWidth': 4}}}%%
flowchart TD
    REQ[LDAP search request] --> ROOT{Root DSE query?}
    ROOT -- Yes --> DSE[Return Root DSE attributes]
    ROOT -- No --> BOUND{Connection authenticated?}
    BOUND -- No --> ERR1[Return operations error]
    BOUND -- Yes --> SE{searchSE configured?}
    SE -- No --> ERR2[Return error — no search handler]
    SE -- Yes --> SEARCH[searchSE]
    SEARCH -- error --> ERR3[Return LDAP error]
    SEARCH -- success --> RES[Return search result entries]

    classDef hook stroke:#6A3EC8,stroke-width:6px
    class SEARCH hook
```

### Simple Bind Authentication

```mermaid theme={null}
%%{init: {'theme': 'neutral', 'themeVariables': {'edgeWidth': 4}}}%%
flowchart TD
    REQ[LDAP Bind request] --> TYPE{Auth type?}
    TYPE -- Simple --> ENABLED{Simple auth enabled?}
    TYPE -- SASL --> SASL[Handle SASL mechanism]
    ENABLED -- No --> ERR1[Return auth method not supported]
    ENABLED -- Yes --> UNAUTH{Password provided?}
    UNAUTH -- No --> ERR2[Return error — password required]
    UNAUTH -- Yes --> SE[authenticateSE]
    SE -- error --> ERR3[Return error]
    SE -- valid --> OK[Bind success — connection authenticated]
    SE -- invalid --> DENY[Return invalid credentials]

    classDef hook stroke:#6A3EC8,stroke-width:6px
    class SE hook
```

### NTLM Authentication (GSSAPI/SPNEGO)

NTLM authentication is a multi-message handshake within a SASL bind. The `getHashedCredentialsSE` hook is called during the final Authenticate phase to retrieve the password hashes needed for verification.

```mermaid theme={null}
%%{init: {'theme': 'neutral', 'themeVariables': {'edgeWidth': 4}}}%%
flowchart TD
    BIND1[SASL Bind: Negotiate message] --> CHAL[Return Challenge message]
    CHAL --> BIND2[SASL Bind: Authenticate message]
    BIND2 --> HASH[getHashedCredentialsSE]
    HASH -- error --> DENY[Return invalid credentials]
    HASH -- success --> OK[Bind success — establish SASL security layer]

    classDef hook stroke:#6A3EC8,stroke-width:6px
    class HASH hook
```

## Hooks

### `searchSE`

Handle LDAP search requests by returning directory entries that match the query. Each entry maps a distinguished name (DN) to its attributes. Use this to implement custom search logic, query external directories, filter results, or build virtual directory entries from non-LDAP sources like databases or REST APIs.

**Signature:**

```go theme={null}
func Search(api orchestrator.Orchestrator, baseDN string, filter string, attrs []string) (map[string]map[string]interface{}, error)
```

**App types:** LDAP Provider

**Config location:** `ldapProvider.search.searchSE`

**Parameters:**

| Parameter | Type                        | Description                                                                   |
| --------- | --------------------------- | ----------------------------------------------------------------------------- |
| `api`     | `orchestrator.Orchestrator` | Access to sessions, caches, secrets, logging, and other Orchestrator services |
| `baseDN`  | `string`                    | The base distinguished name for the search                                    |
| `filter`  | `string`                    | The LDAP search filter expression                                             |
| `attrs`   | `[]string`                  | The list of attribute names to return                                         |

**Returns:**

* `map[string]map[string]interface{}` -- a map of DNs to attribute maps, where each attribute map contains the requested attribute name-value pairs
* `error` -- return `nil` on success, or an error if the search fails

***

### `authenticateSE`

Authenticate a user via LDAP simple bind. Return `true` if the credentials are valid, or `false` to deny authentication. Use this to validate credentials against an external system, implement custom password policies, or bridge LDAP authentication to a non-LDAP identity store.

**Signature:**

```go theme={null}
func Authenticate(api orchestrator.Orchestrator, dn string, password string) (bool, error)
```

**App types:** LDAP Provider

**Config location:** `ldapProvider.authentication.methods.simple.authenticateSE`

**Parameters:**

| Parameter  | Type                        | Description                                                                   |
| ---------- | --------------------------- | ----------------------------------------------------------------------------- |
| `api`      | `orchestrator.Orchestrator` | Access to sessions, caches, secrets, logging, and other Orchestrator services |
| `dn`       | `string`                    | The distinguished name of the user attempting to bind                         |
| `password` | `string`                    | The password provided by the user                                             |

**Returns:**

* `bool` -- `true` if the credentials are valid, `false` otherwise
* `error` -- return `nil` on success, or an error if the authentication process fails

**Examples:**

<AccordionGroup>
  <Accordion title="Delegate simple bind to an IdP via ROPC">
    When an LDAP client performs a simple bind, this extension extracts the
    username from the bind DN and authenticates the user against an OIDC identity
    provider using the Resource Owner Password Credentials (ROPC) grant. This
    bridges LDAP authentication with a modern IdP without changes to the
    client application.

    <Tabs>
      <Tab title="Extension">
        ```go ldap-authenticate.go theme={null}
        package main

        import (
            "errors"
            "fmt"
            "strings"

            "github.com/strata-io/service-extension/idfabric"
            "github.com/strata-io/service-extension/orchestrator"
        )

        func Authenticate(
            api orchestrator.Orchestrator,
            dn string,
            password string,
        ) (bool, error) {
            logger := api.Logger()
            logger.Debug("se", "ldap-auth", "msg", "authenticating user", "dn", dn)

            // Extract the username from the bind DN.
            username, err := parseUsername(dn)
            if err != nil {
                logger.Error("se", "ldap-auth", "msg", "failed to parse DN", "error", err.Error())
                return false, err
            }

            // Delegate authentication to the OIDC provider via ROPC.
            idp, err := api.IdentityProvider("oidc")
            if err != nil {
                logger.Error("se", "ldap-auth", "msg", "failed to get IdP", "error", err)
                return false, err
            }

            var result idfabric.LoginResult
            idp.Login(
                nil,
                nil,
                idfabric.WithGrantTypeROPC(
                    idfabric.ROPCRequest{Username: username, Password: password},
                    &result,
                ),
            )

            if result.Error != nil {
                logger.Error("se", "ldap-auth", "msg", "authentication failed", "error", result.Error)
                return false, result.Error
            }

            return true, nil
        }

        // parseUsername extracts the value of the first RDN from a DN.
        // e.g., "cn=jdoe,ou=users,dc=example,dc=com" -> "jdoe"
        func parseUsername(dn string) (string, error) {
            parts := strings.Split(dn, ",")
            if len(parts) == 0 {
                return "", errors.New("invalid DN format")
            }

            rdn := parts[0]
            eqIdx := strings.Index(rdn, "=")
            if eqIdx < 0 || eqIdx == len(rdn)-1 {
                return "", fmt.Errorf("malformed RDN: %s", rdn)
            }

            return rdn[eqIdx+1:], nil
        }
        ```
      </Tab>

      <Tab title="Configuration">
        ```yaml maverics.yaml theme={null}
        connectors:
          - name: oidc
            type: oidc
            oidcWellKnownURL: https://idp.example.com/.well-known/openid-configuration
            oauthClientID: my-client
            oauthClientSecret: <client_secret>
            scopes: openid profile email

        ldapProvider:
          enabled: true
          uri: ldaps://0.0.0.0:636
          authentication:
            allowAnonymous: false
            methods:
              simple:
                enabled: true
                authenticateSE:
                  file: /etc/maverics/service-extensions/ldap-authenticate.go
                  funcName: Authenticate
        ```
      </Tab>
    </Tabs>
  </Accordion>

  <Accordion title="Add TOTP multi-factor authentication to simple bind">
    Applications that authenticate via LDAP simple bind have no built-in way
    to support multi-factor authentication. This extension works around that
    limitation by treating the password field as a concatenation of the real
    password and a TOTP code (e.g., `myP@ssword12345678`). The password portion
    is verified against an IdP via ROPC, and the TOTP code is verified against
    an external MFA API.

    <Tabs>
      <Tab title="Extension">
        ```go ldap-authenticate-totp.go theme={null}
        package main

        import (
            "encoding/base64"
            "fmt"
            "io"
            "net/http"
            "net/url"
            "strings"

            "github.com/strata-io/service-extension/idfabric"
            "github.com/strata-io/service-extension/orchestrator"
        )

        const (
            // Number of characters at the end of the password that
            // contain the TOTP code.
            totpCodeLength = 8
        )

        func Authenticate(
            api orchestrator.Orchestrator,
            dn string,
            password string,
        ) (bool, error) {
            logger := api.Logger()
            logger.Debug("se", "ldap-auth", "msg", "authenticating user", "dn", dn)

            // Split the password into the real password and appended TOTP code.
            if len(password) < totpCodeLength {
                logger.Debug("se", "ldap-auth", "msg", "password too short to contain TOTP code")
                return false, nil
            }
            actualPassword := password[:len(password)-totpCodeLength]
            totpCode := password[len(password)-totpCodeLength:]

            // Step 1: Verify the password via ROPC against the IdP.
            idp, err := api.IdentityProvider("oidc")
            if err != nil {
                logger.Error("se", "ldap-auth", "msg", "failed to get IdP", "error", err)
                return false, err
            }

            username, err := parseUsername(dn)
            if err != nil {
                return false, err
            }

            var loginResult idfabric.LoginResult
            idp.Login(
                nil,
                nil,
                idfabric.WithGrantTypeROPC(
                    idfabric.ROPCRequest{Username: username, Password: actualPassword},
                    &loginResult,
                ),
            )

            if loginResult.Error != nil {
                logger.Error("se", "ldap-auth", "msg", "password verification failed", "error", loginResult.Error)
                return false, loginResult.Error
            }

            // Step 2: Verify the TOTP code against the MFA provider.
            verified, err := verifyTOTP(api, totpCode)
            if err != nil {
                logger.Error("se", "ldap-auth", "msg", "TOTP verification error", "error", err)
                return false, err
            }

            if !verified {
                logger.Debug("se", "ldap-auth", "msg", "TOTP verification failed")
                return false, nil
            }

            return true, nil
        }

        // verifyTOTP calls an external MFA provider API to verify the TOTP code.
        func verifyTOTP(api orchestrator.Orchestrator, totpCode string) (bool, error) {
            sp := api.SecretProvider()
            accountSID, err := sp.GetString("mfa-account-sid")
            if err != nil {
                return false, fmt.Errorf("failed to get MFA account SID: %w", err)
            }
            authToken, err := sp.GetString("mfa-auth-token")
            if err != nil {
                return false, fmt.Errorf("failed to get MFA auth token: %w", err)
            }
            serviceID, err := sp.GetString("mfa-service-id")
            if err != nil {
                return false, fmt.Errorf("failed to get MFA service ID: %w", err)
            }
            entityID, err := sp.GetString("mfa-entity-id")
            if err != nil {
                return false, fmt.Errorf("failed to get MFA entity ID: %w", err)
            }

            apiURL := fmt.Sprintf(
                "https://verify.example.com/v2/Services/%s/Entities/%s/Challenges",
                serviceID, entityID,
            )

            formData := url.Values{}
            formData.Set("AuthPayload", totpCode)

            req, err := http.NewRequest(http.MethodPost, apiURL, strings.NewReader(formData.Encode()))
            if err != nil {
                return false, fmt.Errorf("failed to create request: %w", err)
            }

            req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
            auth := base64.StdEncoding.EncodeToString([]byte(accountSID + ":" + authToken))
            req.Header.Set("Authorization", "Basic "+auth)

            resp, err := api.HTTP().DefaultClient().Do(req)
            if err != nil {
                return false, fmt.Errorf("failed to call MFA API: %w", err)
            }
            defer resp.Body.Close()

            body, _ := io.ReadAll(resp.Body)
            api.Logger().Debug("se", "ldap-auth", "msg", "MFA API response", "status", resp.StatusCode, "body", string(body))

            return resp.StatusCode == http.StatusCreated, nil
        }

        // parseUsername extracts the value of the first RDN from a DN.
        func parseUsername(dn string) (string, error) {
            parts := strings.Split(dn, ",")
            if len(parts) == 0 {
                return "", fmt.Errorf("invalid DN format")
            }

            rdn := parts[0]
            eqIdx := strings.Index(rdn, "=")
            if eqIdx < 0 || eqIdx == len(rdn)-1 {
                return "", fmt.Errorf("malformed RDN: %s", rdn)
            }

            return rdn[eqIdx+1:], nil
        }
        ```
      </Tab>

      <Tab title="Configuration">
        ```yaml maverics.yaml theme={null}
        connectors:
          - name: oidc
            type: oidc
            oidcWellKnownURL: https://idp.example.com/.well-known/openid-configuration
            oauthClientID: my-client
            oauthClientSecret: <client_secret>
            scopes: openid profile email

        ldapProvider:
          enabled: true
          uri: ldaps://0.0.0.0:636
          authentication:
            allowAnonymous: false
            methods:
              simple:
                enabled: true
                authenticateSE:
                  file: /etc/maverics/service-extensions/ldap-authenticate-totp.go
                  funcName: Authenticate
        ```
      </Tab>
    </Tabs>
  </Accordion>
</AccordionGroup>

***

### `getHashedCredentialsSE`

Provide the password hashes needed for Windows integrated authentication (NTLM). The Orchestrator calls this during the GSSAPI/SPNEGO handshake with the user and domain identifiers, and expects back the pre-computed NT and LM password hashes. Use this to look up hashes from a credential store or compute them from a source system.

**Signature:**

```go theme={null}
func GetHashedCredentials(api orchestrator.Orchestrator, user []byte, domain []byte) ([]byte, []byte, error)
```

**App types:** LDAP Provider

**Config location:** `ldapProvider.authentication.methods.sasl.mechanisms.gssspnego.ntlm.getHashedCredentialsSE`

**Parameters:**

| Parameter | Type                        | Description                                                                   |
| --------- | --------------------------- | ----------------------------------------------------------------------------- |
| `api`     | `orchestrator.Orchestrator` | Access to sessions, caches, secrets, logging, and other Orchestrator services |
| `user`    | `[]byte`                    | The user identifier for the NTLM authentication                               |
| `domain`  | `[]byte`                    | The domain identifier for the NTLM authentication                             |

**Returns:**

* `[]byte` -- the NT password hash for the user
* `[]byte` -- the LM password hash for the user
* `error` -- return `nil` on success, or an error if the credential lookup fails

## Related Pages

<CardGroup cols={2}>
  <Card title="Service Extensions Overview" icon="code" href="/reference/orchestrator/service-extensions">
    Configuration, SDK reference, and best practices
  </Card>

  <Card title="LDAP Provider Mode" icon="folder-tree" href="/reference/modes/ldap-provider">
    LDAP provider mode configuration and setup
  </Card>
</CardGroup>
