diff --git a/config/schema/uw_cfg_common.schema.yml b/config/schema/uw_cfg_common.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b3bb66d01f5b596af45d39d35ef1955395cec500
--- /dev/null
+++ b/config/schema/uw_cfg_common.schema.yml
@@ -0,0 +1,10 @@
+uw_cfg_common.ldap:
+  type: config_object
+  label: 'LDAP settings'
+  mapping:
+    user:
+      type: string
+      label: 'LDAP user name'
+    pwd:
+      type: string
+      label: 'LDAP password'
diff --git a/src/Service/UWLdap.php b/src/Service/UWLdap.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c0bfdb10dc07f1dbfe975faf3fe46b8f05582a1
--- /dev/null
+++ b/src/Service/UWLdap.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace Drupal\uw_cfg_common\Service;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Logger\LoggerChannelTrait;
+use Drupal\Core\Session\AccountProxy;
+
+/**
+ * Class for interfacing with UW's LDAP infrastructure.
+ */
+class UWLdap {
+
+  use LoggerChannelTrait;
+
+  /**
+   * Information about the LDAP server.
+   *
+   * @var array
+   */
+  protected $ldapServer = [
+    'server' => 'ldaps://ldap-nexus.uwaterloo.ca',
+    'base_dn' => 'dc=nexus, dc=uwaterloo, dc=ca',
+    // The name of the UID field.
+    'uid' => 'sAMAccountName',
+    // Config keys in uw_cfg_common.ldap from which to load the user and
+    // password.
+    'user' => 'user',
+    'pwd' => 'pwd',
+  ];
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The current user object.
+   *
+   * @var \Drupal\Core\Session\AccountProxy
+   */
+  protected $currentUser;
+
+  /**
+   * Constructor with dependency injection.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Session\AccountProxy $current_user
+   *   The current user object.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, AccountProxy $current_user) {
+    $this->configFactory = $config_factory;
+    $this->currentUser = $current_user;
+  }
+
+  /**
+   * Open a connection to the LDAP server.
+   *
+   * @return bool
+   *   TRUE if the connection succeeded, FALSE otherwise.
+   */
+  protected function openConnection(): bool {
+    // Return early if opening a connection has already been attempted. It will
+    // not retry after a failed connection attempt.
+    if (isset($this->ldapServer['connection'])) {
+      return (bool) $this->ldapServer['connection'];
+    }
+
+    // Load LDAP user and password from config.
+    foreach (['user', 'pwd'] as $field) {
+      if (isset($this->ldapServer[$field])) {
+        $this->ldapServer[$field] = $this->configFactory->get('uw_cfg_common.ldap')->get($this->ldapServer[$field]);
+      }
+      else {
+        $this->ldapServer[$field] = NULL;
+      }
+    }
+
+    // Do not attempt connection if no username or password.
+    if (!isset($this->ldapServer['user']) || !isset($this->ldapServer['pwd'])) {
+      $this->getLogger('uw_cfg_common')->error('Unable to connect to LDAP server. Missing username or password.');
+      $this->ldapServer['connection'] = FALSE;
+      return FALSE;
+    }
+
+    // Connect and bind to the server. Log errors if any.
+    $ldap_connect = ldap_connect($this->ldapServer['server']);
+    if (!$ldap_connect || !ldap_bind($ldap_connect, $this->ldapServer['user'], $this->ldapServer['pwd'])) {
+      // Log errors on failure.
+      ldap_get_option($ldap_connect, LDAP_OPT_ERROR_NUMBER, $error_number);
+      ldap_get_option($ldap_connect, LDAP_OPT_DIAGNOSTIC_MESSAGE, $error_message);
+      ldap_get_option($ldap_connect, LDAP_OPT_ERROR_STRING, $error_string);
+      $variables = [
+        '@error_number' => $error_number,
+        '@error_message' => $error_message,
+        '@error_string' => $error_string,
+      ];
+      $this->getLogger('uw_cfg_common')->error('Unable to connect to LDAP server. Error number: @error_number; Error message: @error_message; Error string: @error_string', $variables);
+      $ldap_connect = FALSE;
+    }
+    // Store connection object.
+    $this->ldapServer['connection'] = $ldap_connect;
+
+    return (bool) $ldap_connect;
+  }
+
+  /**
+   * Wrapper for ldap_search() and ldap_get_entries().
+   *
+   * @param string $filter
+   *   Filter for the LDAP search.
+   * @param array $attributes
+   *   Attributes to the LDAP search.
+   *
+   * @return array|null
+   *   Array of results from LDAP search or NULL on error.
+   */
+  public function search(string $filter, array $attributes = []): ?array {
+    if (!$this->openConnection()) {
+      return NULL;
+    }
+
+    // Attempt search and get the entries if successful. Otherwise, return NULL.
+    $results = ldap_search($this->ldapServer['connection'], $this->ldapServer['base_dn'], $filter, $attributes);
+    if ($results) {
+      $results = ldap_get_entries($this->ldapServer['connection'], $results);
+      if ($results) {
+        return $results;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Do an LDAP lookup for a person.
+   *
+   * @param string $username
+   *   The user to lookup.
+   *
+   * @return array|null
+   *   An array of LDAP results or NULL on failure.
+   */
+  public function lookupPerson(string $username): ?array {
+    // Configure the userid filter.
+    $filter = '(' . $this->ldapServer['uid'] . '=' . $username . ')';
+
+    $result = $this->search($filter);
+
+    // Return formatted results if the search is successful.
+    if (isset($result[0])) {
+      return $this->formatResults($result[0]);
+    }
+    return NULL;
+  }
+
+  /**
+   * Make formatting changes to LDAP query results.
+   *
+   * @param array $results
+   *   The LDAP query results.
+   *
+   * @return array
+   *   The input array with formatting changes.
+   */
+  public static function formatResults(array $results): array {
+    foreach ($results as $key => $value) {
+      if (is_array($value)) {
+        foreach ($value as $sub_key => $sub_value) {
+          if (is_string($sub_value)) {
+            if (in_array($key, ['objectguid', 'objectsid', 'sidhistory'], TRUE)) {
+              $results[$key][$sub_key] = self::formatGuid($sub_value);
+            }
+            else {
+              $results[$key][$sub_key] = utf8_encode($sub_value);
+            }
+          }
+        }
+      }
+    }
+    return $results;
+  }
+
+  /**
+   * Converts a binary guid into a string representation.
+   *
+   * @param string $guid
+   *   The guid as a binary string.
+   *
+   * @return string
+   *   The guid as a displayable string.
+   */
+  protected static function formatGuid(string $guid): string {
+    $guid = bin2hex($guid);
+
+    // Format $guid so it matches what other LDAP tool displays for AD GUIDs.
+    if (strlen($guid) === 32) {
+      $guid = strtoupper(substr($guid, 6, 2) . substr($guid, 4, 2) . substr($guid, 2, 2) . substr($guid, 0, 2) . '-' . substr($guid, 10, 2) . substr($guid, 8, 2) . '-' . substr($guid, 14, 2) . substr($guid, 12, 2) . '-' . substr($guid, 16, 4) . '-' . substr($guid, 20));
+    }
+
+    return $guid;
+  }
+
+}
diff --git a/uw_cfg_common.services.yml b/uw_cfg_common.services.yml
index 49c1f917d40f2d10083a3e0ce514a208b7882851..f23639002ca86d8ed2fa7ceb3098fa8a4dde288a 100644
--- a/uw_cfg_common.services.yml
+++ b/uw_cfg_common.services.yml
@@ -1,4 +1,7 @@
 services:
+  uw_cfg_common.uw_ldap:
+    class: Drupal\uw_cfg_common\Service\UWLdap
+    arguments: ['@config.factory', '@current_user']
   uw_cfg_common.uw_service:
     class: Drupal\uw_cfg_common\Service\UWService
     arguments: ['@entity_type.manager', '@database', '@simplify_menu.menu_items', '@path_alias.manager']