Skip to content

Instantly share code, notes, and snippets.

@yumenohikari
Last active July 22, 2025 22:21
Show Gist options
  • Save yumenohikari/8440144023cf33ab3ef0d68084a1b42f to your computer and use it in GitHub Desktop.
Save yumenohikari/8440144023cf33ab3ef0d68084a1b42f to your computer and use it in GitHub Desktop.
Active Directory LDAP auth for Home Assistant

Active Directory LDAP auth for Home Assistant

This script allows users to log in to Home Assistant using their sAMAccountName or userPrincipalName identifiers without any special requirements for the ldapsearch or curl utilities. Instead, it requires the ldap3 Python module, but there are ways to install that locally so it can even be used in supervised / Home Assistant OS installs.

Editing for use in your installation

Obviously most of the configuration values in the script need to be edited to work in your environment.

  • SERVER - the DNS name of your AD domain, or the name or IP of a specific domain controller.
  • HELPERDN - the DN (distinguishedName attribute) of the service account you're using to search LDAP for the desired user.
  • HELPERPASS - the password for that service account.
  • TIMEOUT - LDAP search timeout in seconds.
  • FILTER - LDAP search filter to find the desired user. To match by SAM name or UPN and a group membership, just edit the memberOf line to include the DN of the group you want to use to control access.
  • BASEDN - the DN of the top-most container to search. To search the entire domain, use just the "DC" sections at the end of your domain's DNs, e.g. DC=ad,DC=example,DC=com. As written, the script searches recursively.

Authentication configuration

In a Home Assistant Core installation, you can install the Python module using pip or your package manager, then put the script in any directory where Home Assistant can reach it. Then add a section to configuration.yaml:

homeassistant:
    auth_providers:
        - type: command_line
          command: /usr/local/bin/ldap-auth-ad.py
          meta: true
        - type: homeassistant

Note that homeassistant must be explicitly specified as an authentication method, or you won't have access to locally-created users.

Installing in a Docker-based installation

Because Python modules can be installed in and loaded from the current path, it's possible to make this work in Docker containers as well, by hiding it in the /config directory.

First, make a directory to contain everything, and copy the configured script into the host directory that's mounted as /config:

me@host:~ $ sudo mkdir /usr/share/hassio/homeassistant/ldap-auth
me@host:~ $ sudo cp ldap-auth-ad.py /usr/share/hassio/homeassistant/ldap-auth

Next, open a shell in the Home Assistant core container, and change to the directory we just created:

me@host:~ $ sudo docker exec -it homeassistant bash
bash-5.0# cd /config/ldap-auth

Install the module:

bash-5.0# pip install -t . ldap3

And insert the configuration section (note the modified path):

homeassistant:
    auth_providers:
        - type: command_line
          command: /config/ldap-auth/ldap-auth-ad.py
          meta: true
        - type: homeassistant

Finally, restart the entire application (Configuration > Server Controls > Server Management > Restart) to reload the config. (It may be possible to reload without doing this, but I'm not entirely clear on when configuration.yaml is read.)

You should now be able to log in as any user that's a member of the group you picked above. Home Assistant will create a new user in the local database the first time a user logs in.

Credits

This whole thing is hacked out of a more generic LDAP script by Rechner Fox. I mostly tweaked the filters and added the username search.

#!/usr/bin/env python
# ldap-auth-ad.py - authenticate Home Assistant against AD via LDAP
# Based on Rechner Fox's ldap-auth.py
# Original found at https://gist.github.com/rechner/57c123d243b8adb83ccb1dc94c80847f
import os
import sys
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_bytes, escape_filter_chars
# Quick and dirty print to stderr
def eprint(*args, **kwargs):
print(*args, file=sys.stderr, **kwargs)
# XXX: Update these with settings apropriate to your environment:
# (mine below are based on Active Directory and a security group)
SERVER = "ad.example.com"
# We need to search by SAM/UPN to find the DN, so we use a helper account
# This account should be unprivileged and blocked from interactive logon
HELPERDN = "CN=LDAP Helper,OU=Service Accounts,OU=Accounts,DC=ad,DC=example,DC=com"
HELPERPASS = "sEcUrEpAsSwOrD"
TIMEOUT = 3
BASEDN = "DC=ad,DC=example,DC=com"
FILTER = """
(&
(objectClass=person)
(|
(sAMAccountName={})
(userPrincipalName={})
)
(memberOf=CN=Home Assistant,OU=Security Groups,OU=Accounts,DC=ad,DC=example,DC=com)
)"""
ATTRS = ""
## End config section
if 'username' not in os.environ or 'password' not in os.environ:
eprint("Need username and password environment variables!")
exit(1)
safe_username = escape_filter_chars(os.environ['username'])
FILTER = FILTER.format(safe_username, safe_username)
server = Server(SERVER, get_info=ALL)
try:
conn = Connection(server, HELPERDN, password=HELPERPASS, auto_bind=True, raise_exceptions=True)
except Exception as e:
eprint("initial bind failed: {}".format(e))
exit(1)
search = conn.search(BASEDN, FILTER, attributes='displayName')
if len(conn.entries) > 0: # search is True on success regardless of result size
eprint("search success: username {}, result {}".format(os.environ['username'], conn.entries))
user_dn = conn.entries[0].entry_dn
user_displayName = conn.entries[0].displayName
else:
eprint("search for username {} yielded empty result".format(os.environ['username']))
exit(1)
try:
conn.rebind(user=user_dn, password=os.environ['password'])
except Exception as e:
eprint("bind as {} failed: {}".format(os.environ['username'], e))
exit(1)
print("name = {}".format(user_displayName))
eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)
@justinhunt1223
Copy link

justinhunt1223 commented Apr 10, 2025

I'll toss in here what worked for me. I'm running HAOS and using Zentyal as my domain controller. If you haven't looked at using Zentyal, I highly recommend it. It's a free drop in Active Directory replacement and works wonderfully, especially for us home users.

I have the auth script installing the ldap3 module. This ensures anytime the script runs, the module is available, so no need to add that step anywhere else. It will take a few extra seconds during the first run (which is the first time anyone tries to log in using LDAP) because it needs to install the module from pip. There is no delay afterward for any user trying to log in, as the ldap3 module is not installed system-wide.

I made some changes to the script as well, notably making it a little more readable and adding groups for regular users and admin users. I have a family with users of different privilege levels, so everyone is part of a group called People for all the services that I use LDAP authentication for (which is growing seemingly monthly). I don't create groups for every different service, so I don't have a specific LDAP group for home assistant users. I have an admins group that is used for admins of any service that LDAP uses to authenticate against. This keeps my setup clean and easy to manage.

configuration.yaml

homeassistant:
  auth_providers:
    - type: homeassistant
    - type: command_line
      name: "LDAP"
      command: "/usr/local/bin/python3"
      args: ["/config/scripts/ldap-auth-ad.py"]
      meta: true

ldap-auth-ad.py

#!/usr/bin/env python

#########################################
# Config section
#########################################
LDAP_SERVER = "ldap://dc.example.com:389"
LDAP_LOOKUP_USER = "[email protected]"
LDAP_LOOKUP_PASSWORD = "mypassword"
LDAP_TIMEOUT = 3
LDAP_USER_BASE_DN = "DC=example,DC=com"
LDAP_USER_GROUP = "People"
LDAP_ADMIN_GROUP = "Admins"
LDAP_SEARCH_FILTER = f"""
(&
    (objectClass=person)
    (|
        (sAMAccountName={{}})
        (userPrincipalName={{}})
    )
    (memberOf=CN={LDAP_USER_GROUP},CN=Groups,{LDAP_USER_BASE_DN})
)"""

#########################################
# Ensure pip dependencies are installed.
# ***The first login will take a bit***
#########################################

import importlib.util
import subprocess

def install_pip_package(package):
    if importlib.util.find_spec(package) is None:
        subprocess.check_call(
            ['/usr/bin/env', 'pip', 'install', package, '--break-system-packages'],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.STDOUT
        )

install_pip_package('ldap3')

import os
import sys
from ldap3 import Server, Connection, ALL
from ldap3.utils.conv import escape_bytes, escape_filter_chars

#########################################
# The auth script
#
# ldap-auth-ad.py - authenticate Home Assistant against AD via LDAP
# Based on Rechner Fox's ldap-auth.py
# Original found at https://gist.github.com/rechner/57c123d243b8adb83ccb1dc94c80847f
# Further modified from https://gist.github.com/yumenohikari/8440144023cf33ab3ef0d68084a1b42f
#########################################

def eprint(*args, **kwargs):
    print(*args, file=sys.stderr, **kwargs)

if 'username' not in os.environ or 'password' not in os.environ:
    eprint("Need username and password environment variables!")
    exit(2)

safe_username = escape_filter_chars(os.environ['username'])
LDAP_SEARCH_FILTER = LDAP_SEARCH_FILTER.format(safe_username, safe_username)

ldap_server = Server(LDAP_SERVER, get_info=ALL)
try:
    ldap_connection = Connection(
        ldap_server,
        LDAP_LOOKUP_USER,
        password=LDAP_LOOKUP_PASSWORD,
        auto_bind=True,
        raise_exceptions=True
    )
except Exception as e:
    eprint("initial bind failed: {}".format(e))
    exit(3)

ldap_search = ldap_connection.search(
    LDAP_USER_BASE_DN,
    LDAP_SEARCH_FILTER,
    attributes=['displayName','memberof']
)
if len(ldap_connection.entries) > 0: # search is True on success regardless of result size
    eprint("search success: username {}, result {}".format(os.environ['username'], ldap_connection.entries))
    found_user_dn = ldap_connection.entries[0].entry_dn
    found_user_display_name = ldap_connection.entries[0].displayName
    found_user_member_of_list = ldap_connection.entries[0].memberof
else:
    eprint("search for username {} yielded empty result".format(os.environ['username']))
    exit(4)

try:
    ldap_connection.rebind(
        user=found_user_dn,
        password=os.environ['password']
    )
except Exception as e:
    eprint("bind as {} failed: {}".format(os.environ['username'], e))
    exit(5)

if f"CN={LDAP_ADMIN_GROUP},CN=Groups,{LDAP_USER_BASE_DN}" in found_user_member_of_list:
    print("name = {}".format(found_user_display_name),"group = system-admin",sep=os.linesep)
elif f"CN={LDAP_USER_GROUP},CN=Groups,{LDAP_USER_BASE_DN}" in found_user_member_of_list:
    print("name = {}".format(found_user_display_name),"group = system-users",sep=os.linesep)

eprint("{} authenticated successfully".format(os.environ['username']))
exit(0)

@Wyphorn
Copy link

Wyphorn commented Jul 22, 2025

I still got exception "command exited with code 1". Is there any possibility to debug the python script? I am able to run a modified version of that directly in the Python IDLE, but on HA it is just failing.
But the whole printlines in are not in the HA logs. So I dont know if the code is the problem or my folder structure with the script. Can someone point me?

@justinhunt1223
Copy link

I still got exception "command exited with code 1". Is there any possibility to debug the python script? I am able to run a modified version of that directly in the Python IDLE, but on HA it is just failing. But the whole printlines in are not in the HA logs. So I dont know if the code is the problem or my folder structure with the script. Can someone point me?

Change home assistant log level to debug, restart HA, and see if there's any more info. I'd be leaning toward directory structure/file location being more that issue as it took me a bunch of tries to get it right.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment