Skip to main content
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.

Simple Bind Authentication

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.

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:
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:
ParameterTypeDescription
apiorchestrator.OrchestratorAccess to sessions, caches, secrets, logging, and other Orchestrator services
baseDNstringThe base distinguished name for the search
filterstringThe LDAP search filter expression
attrs[]stringThe 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:
func Authenticate(api orchestrator.Orchestrator, dn string, password string) (bool, error)
App types: LDAP Provider Config location: ldapProvider.authentication.methods.simple.authenticateSE Parameters:
ParameterTypeDescription
apiorchestrator.OrchestratorAccess to sessions, caches, secrets, logging, and other Orchestrator services
dnstringThe distinguished name of the user attempting to bind
passwordstringThe password provided by the user
Returns:
  • booltrue if the credentials are valid, false otherwise
  • error — return nil on success, or an error if the authentication process fails
Examples:
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.
ldap-authenticate.go
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
}
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.
ldap-authenticate-totp.go
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
}

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:
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:
ParameterTypeDescription
apiorchestrator.OrchestratorAccess to sessions, caches, secrets, logging, and other Orchestrator services
user[]byteThe user identifier for the NTLM authentication
domain[]byteThe 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