WP Simple Azure AD Login

I have been searching every now and then a plugin that would easily and freely integrate WordPress to Azure AD (and O365), but the ones I have found from WordPress plugin register have not been suitable for my use for one reason or another: https://wordpress.org/plugins/tags/azure-ad/

There seems to be one that looks promising but it has not been registered to WordPress plugins yet, so it is a manual installation like the one I have created, but it seems to have more features: https://github.com/psignoret/aad-sso-wordpress

However, since I created couple of weeks ago my own example code how to communicate to Azure AD with PHP, I concluded that it might be interesting to see what it takes to create a WordPress plugin out of that. I must warn that the code I’m about to show you is not the nicest one and basically this just enables Azure AD authentication for already registered users if WordPress username and Azure AD userPrincipalName match.

WordPress Login form with added Azure Active Directory login button.

Here is the code that WordPress actually considers as a plugin: (wp-simple-azure-ad-login.php)

<?php
/*
Plugin Name: WP Simple Azure AD Login
Plugin URI: https://www.sipponen.com/archives/4149
Description: Adds Azure AD based authentication to WP via Oauth2
Version: 0.1
License: The MIT License
License URI: https://opensource.org/licenses/MIT
Author: Sami Sipponen
Author URI: https://www.sipponen.com/

Copyright 2020 Sami Sipponen <sami@sipponen.com>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/

function login_page_add_aad_form()
{
    global $redirect_to;
    session_name("simple_azure_ad_autentication");
    session_start();
    $_SESSION = get_option('wp_simple_azure_ad_login_option_name');
    $_SESSION["admin_email"] = get_option('admin_email');
?>
    <script type="text/javascript">
        var loginDiv = document.getElementById('login');
        var aadForm = '</form><form  name="aad-loginform" id="aad-loginform" action="<?php echo esc_url(site_url('wp-content/plugins/wp-simple-azure-ad-login/login.php', 'login_post')); ?>" method="post">\
    <p style="margin-top: 20px; margin-bottom: 20px; font-weight: bold;">Single Sign On with Azure Active Directory:</p>\
    <p class="submit"><input name="aad-submit" id="aad-submit" class="button button-primary button-large" type="submit" value="Login with Azure AD"></p>\
    <input type="hidden" name="redirect_to" value="<?php echo esc_url($redirect_to); ?>">\
    <input type="hidden" name="testcookie" value="1">\
    <input type="hidden" name="wp-login-url" value="<?php echo esc_url(site_url('wp-login.php', 'login_post')); ?>">\
    </form>';
        loginDiv.innerHTML = loginDiv.innerHTML.replace('</form>', aadForm);
    </script>
<?php
}

function simple_azure_ad_autentication($user, $username, $password)
{
    if (isset($_POST["aad-submit"])) {
        session_name("simple_azure_ad_autentication");
        session_start();
        if ($_SESSION["aad-logon-ok"]) {
            $userobj = new WP_User();
            $user = $userobj->get_data_by('login', $_SESSION["userPrincipalName"]);
            $user = new WP_User($user->ID);

            //Cleanups
            $_SESSION = array();
            session_destroy();
            remove_action('authenticate', 'wp_authenticate_username_password', 20);

            if ($user->ID == 0) {
                $user = new WP_Error('denied', __("ERROR: Not a valid user for this system"));
                //Another possibility would be to create the user here
                //We could for example check AAD user group membership and add WP user role according to those
            } else {
                return ($user);
            }
        } else {
            $_SESSION = array();
            session_destroy();
        }
    }
}

add_action('login_footer', 'login_page_add_aad_form');
add_filter('authenticate', 'simple_azure_ad_autentication', 10, 3);

include __DIR__ . '/admin.php';
?>

Here is the login code based on my earlier example: (login.php)

<?php
//This login script is based on Sami Sipponen's Simple Azure Oauth2 Example with PHP:
//https://www.sipponen.com/archives/4024

session_name("simple_azure_ad_autentication");
session_start();

function errorhandler($input)
{
    if (!empty($_SESSION["email_errors"])) {
        $output = "PHP Session ID:    " . session_id() . PHP_EOL;
        $output .= "Client IP Address: " . getenv("REMOTE_ADDR") . PHP_EOL;
        $output .= "Client Browser:    " . $_SERVER["HTTP_USER_AGENT"] . PHP_EOL;
        $output .= PHP_EOL;
        ob_start();  //Start capturing the output buffer
        var_dump($input);  //This is not for debug print, this is to collect the data for the email
        $output .= ob_get_contents();  //Storing the output buffer content to $output
        ob_end_clean();  //While testing, you probably want to comment the next row out
        mb_send_mail($_SESSION["admin_email"], "Your Azure AD Oauth2 script faced an error!", $output, "X-Priority: 1\nContent-Transfer-Encoding: 8bit\nX-Mailer: PHP/" . phpversion());
    }
    exit;
}

if (isset($_POST["wp-login-url"]) and isset($_POST["aad-submit"])) {  //Real authentication part begins
    $_SESSION["post_vars"] = $_POST;
    //First stage of the authentication process; This is just a simple redirect (first load of this page)
    $url = "https://login.microsoftonline.com/" . $_SESSION["tenant_id"] . "/oauth2/v2.0/authorize?";
    $url .= "state=" . session_id();  //This at least semi-random string is likely good enough as state identifier
    $url .= "&scope=User.Read";  //This scope seems to be enough, but you can try "&scope=profile+openid+email+offline_access+User.Read" if you like
    $url .= "&response_type=code";
    $url .= "&approval_prompt=auto";
    $url .= "&client_id=" . $_SESSION["client_id"];
    $url .= "&redirect_uri=" . urlencode($_SESSION["redirect_uri"]);
    header("Location: " . $url);  //So off you go my dear browser and welcome back for round two after some redirects at Azure end

} elseif (isset($_GET["error"])) {  //Second load of this page begins, but hopefully we end up to the next elseif section...
    echo "Some error occurred.";
    $_SESSION["client_secret"] = "<hidden-from-email>";
    errorhandler(array("Description" => "Error received at the beginning of second stage.", "\$_GET[]" => $_GET, "\$_SESSION[]" => $_SESSION));
} elseif (strcmp(session_id(), $_GET["state"]) == 0) {  //Checking that the session_id matches to the state for security reasons
    //And now the browser has returned from its various redirects at Azure side and carrying some gifts inside $_GET

    //Verifying the received tokens with Azure and finalizing the authentication part
    $content = "grant_type=authorization_code";
    $content .= "&client_id=" . $_SESSION["client_id"];
    $content .= "&redirect_uri=" . urlencode($_SESSION["redirect_uri"]);
    $content .= "&code=" . $_GET["code"];
    $content .= "&client_secret=" . urlencode($_SESSION["client_secret"]);
    $options = array(
        "http" => array(  //Use "http" even if you send the request with https
            "method"  => "POST",
            "header"  => "Content-Type: application/x-www-form-urlencoded\r\n" .
                "Content-Length: " . strlen($content) . "\r\n",
            "content" => $content
        )
    );
    $context  = stream_context_create($options);
    $json = file_get_contents("https://login.microsoftonline.com/" . $_SESSION["tenant_id"] . "/oauth2/v2.0/token", false, $context);
    if ($json === false) errorhandler(array("Description" => "Error received during Bearer token fetch.", "PHP_Error" => error_get_last(), "\$_GET[]" => $_GET, "HTTP_msg" => $options));
    $authdata = json_decode($json, true);
    if (isset($authdata["error"])) errorhandler(array("Description" => "Bearer token fetch contained an error.", "\$authdata[]" => $authdata, "\$_GET[]" => $_GET, "HTTP_msg" => $options));

    //Fetching the basic user information that is likely needed by your application
    $options = array(
        "http" => array(  //Use "http" even if you send the request with https
            "method" => "GET",
            "header" => "Accept: application/json\r\n" .
                "Authorization: Bearer " . $authdata["access_token"] . "\r\n"
        )
    );
    $context = stream_context_create($options);
    $json = file_get_contents("https://graph.microsoft.com/v1.0/me", false, $context);
    if ($json === false) errorhandler(array("Description" => "Error received during user data fetch.", "PHP_Error" => error_get_last(), "\$_GET[]" => $_GET, "HTTP_msg" => $options));
    $userdata = json_decode($json, true);  //This should now contain your logged on user information
    if (isset($userdata["error"])) errorhandler(array("Description" => "User data fetch contained an error.", "\$userdata[]" => $userdata, "\$authdata[]" => $authdata, "\$_GET[]" => $_GET, "HTTP_msg" => $options));

    $wp_login = htmlspecialchars($_SESSION["post_vars"]["wp-login-url"]);
    unset($_SESSION["post_vars"]["wp-login-url"]);
?>
    <html>

    <head>
        <title>WP Simple Azure AD Login - Redirecting...</title>
    </head>

    <body onload="document.forms['loginform'].submit();">
        <form method="POST" action="<?php echo $wp_login; ?>" name="loginform" target="_top">
            <?php
            foreach ($_SESSION["post_vars"] as $key => $value) {
                echo "                <input type= \"hidden\" name=\"" . $key . "\" value=\"" . $value . "\">" . PHP_EOL;
            }
            ?>
        </form>
    </body>

    </html>
<?php
    $_SESSION = $userdata;
    $_SESSION["aad-logon-ok"] = true;

} else {
    //If we end up here, something has obviously gone wrong... Likely a hacking attempt since sent and returned state aren't matching and no $_GET["error"] received.
    echo "Hey, please don't try to hack us!";
    $_SESSION["client_secret"] = "<hidden-from-email>";
    errorhandler(array("Description" => "Likely a hacking attempt, due state mismatch or missing POST variable (wp-login-url or aad-submit).", "\$_GET[]" => $_GET, "\$_POST[]" => $_POST, "\$_SESSION[]" => $_SESSION));
}

And here is the admin page code, since basically all WP plugins should have one: (admin.php)

<?php
/*
 Generated by the WordPress Option Page generator
 at http://jeremyhixon.com/wp-tools/option-page/
 And then manually modified...
*/

class WPSimpleAzureADLogin
{
    private $wp_simple_azure_ad_login_options;

    public function __construct()
    {
        add_action('admin_menu', array($this, 'wp_simple_azure_ad_login_add_plugin_page'));
        add_action('admin_init', array($this, 'wp_simple_azure_ad_login_page_init'));
    }

    public function wp_simple_azure_ad_login_add_plugin_page()
    {
        add_options_page(
            'WP Simple Azure AD Login', // page_title
            'WP Simple Azure AD Login', // menu_title
            'manage_options', // capability
            'wp-simple-azure-ad-login', // menu_slug
            array($this, 'wp_simple_azure_ad_login_create_admin_page') // function
        );
    }

    public function wp_simple_azure_ad_login_create_admin_page()
    {
        $this->wp_simple_azure_ad_login_options = get_option('wp_simple_azure_ad_login_option_name');
        if (empty($this->wp_simple_azure_ad_login_options['redirect_uri'])) $this->wp_simple_azure_ad_login_options['redirect_uri'] = plugin_dir_url(__FILE__) . "login.php";
?>

        <div class="wrap">
            <h2>WP Simple Azure AD Login</h2>
            <p>This plugin enables Azure AD Single Sign On functionality for your WordPress. To do that it needs application registration to Azure AD and related configurations need to match at both ends.</p>
            <?php settings_errors(); ?>

            <form method="post" action="options.php">
                <?php
                settings_fields('wp_simple_azure_ad_login_option_group');
                do_settings_sections('wp-simple-azure-ad-login-admin');
                submit_button();
                ?>
            </form>
        </div>
<?php
    }

    public function wp_simple_azure_ad_login_page_init()
    {
        register_setting(
            'wp_simple_azure_ad_login_option_group', // option_group
            'wp_simple_azure_ad_login_option_name', // option_name
            array($this, 'wp_simple_azure_ad_login_sanitize') // sanitize_callback
        );

        add_settings_section(
            'wp_simple_azure_ad_login_setting_section', // id
            'Settings', // title
            array($this, 'wp_simple_azure_ad_login_section_info'), // callback
            'wp-simple-azure-ad-login-admin' // page
        );

        add_settings_field(
            'client_id', // id
            'Azure Application (client) ID', // title
            array($this, 'client_id_callback'), // callback
            'wp-simple-azure-ad-login-admin', // page
            'wp_simple_azure_ad_login_setting_section' // section
        );

        add_settings_field(
            'tenant_id', // id
            'Azure AD Tenant ID', // title
            array($this, 'tenant_id_callback'), // callback
            'wp-simple-azure-ad-login-admin', // page
            'wp_simple_azure_ad_login_setting_section' // section
        );

        add_settings_field(
            'client_secret', // id
            'Client Secret (generated at Azure)', // title
            array($this, 'client_secret_callback'), // callback
            'wp-simple-azure-ad-login-admin', // page
            'wp_simple_azure_ad_login_setting_section' // section
        );

        add_settings_field(
            'redirect_uri', // id
            'Redirect URI (your logon page address)', // title
            array($this, 'redirect_uri_callback'), // callback
            'wp-simple-azure-ad-login-admin', // page
            'wp_simple_azure_ad_login_setting_section' // section
        );

        add_settings_field(
            'email_errors', // id
            'Send errors via email', // title
            array($this, 'email_errors_callback'), // callback
            'wp-simple-azure-ad-login-admin', // page
            'wp_simple_azure_ad_login_setting_section' // section
        );
    }

    public function wp_simple_azure_ad_login_sanitize($input)
    {
        $sanitary_values = array();
        if (isset($input['client_id'])) {
            $sanitary_values['client_id'] = sanitize_text_field($input['client_id']);
        }

        if (isset($input['tenant_id'])) {
            $sanitary_values['tenant_id'] = sanitize_text_field($input['tenant_id']);
        }

        if (isset($input['client_secret'])) {
            $sanitary_values['client_secret'] = sanitize_text_field($input['client_secret']);
        }

        if (isset($input['redirect_uri'])) {
            $sanitary_values['redirect_uri'] = sanitize_text_field($input['redirect_uri']);
        }

        if (isset($input['email_errors'])) {
            $sanitary_values['email_errors'] = $input['email_errors'];
        }

        return $sanitary_values;
    }

    public function wp_simple_azure_ad_login_section_info()
    {
    }

    public function client_id_callback()
    {
        printf(
            '<input class="regular-text" type="text" name="wp_simple_azure_ad_login_option_name[client_id]" id="client_id" value="%s">',
            isset($this->wp_simple_azure_ad_login_options['client_id']) ? esc_attr($this->wp_simple_azure_ad_login_options['client_id']) : ''
        );
    }

    public function tenant_id_callback()
    {
        printf(
            '<input class="regular-text" type="text" name="wp_simple_azure_ad_login_option_name[tenant_id]" id="tenant_id" value="%s">',
            isset($this->wp_simple_azure_ad_login_options['tenant_id']) ? esc_attr($this->wp_simple_azure_ad_login_options['tenant_id']) : ''
        );
    }

    public function client_secret_callback()
    {
        printf(
            '<input class="regular-text" type="text" name="wp_simple_azure_ad_login_option_name[client_secret]" id="client_secret" value="%s">',
            isset($this->wp_simple_azure_ad_login_options['client_secret']) ? esc_attr($this->wp_simple_azure_ad_login_options['client_secret']) : ''
        );
    }

    public function redirect_uri_callback()
    {
        printf(
            '<input class="regular-text" type="text" name="wp_simple_azure_ad_login_option_name[redirect_uri]" id="redirect_uri" value="%s">',
            isset($this->wp_simple_azure_ad_login_options['redirect_uri']) ? esc_attr($this->wp_simple_azure_ad_login_options['redirect_uri']) : ''
        );
    }

    public function email_errors_callback()
    {
        printf(
            '<input type="checkbox" name="wp_simple_azure_ad_login_option_name[email_errors]" id="email_errors" value="email_errors" %s> <label for="email_errors">Send errors to administrator\'s email address.</label>',
            (isset($this->wp_simple_azure_ad_login_options['email_errors']) && $this->wp_simple_azure_ad_login_options['email_errors'] === 'email_errors') ? 'checked' : ''
        );
    }
}
if (is_admin())
    $wp_simple_azure_ad_login = new WPSimpleAzureADLogin();

/* 
 * Retrieve this value with:
 * $wp_simple_azure_ad_login_options = get_option( 'wp_simple_azure_ad_login_option_name' ); // Array of All Options
 * $client_id = $wp_simple_azure_ad_login_options['client_id']; // Azure Application (client) ID
 * $tenant_id = $wp_simple_azure_ad_login_options['tenant_id']; // Azure AD Tenant ID
 * $client_secret = $wp_simple_azure_ad_login_options['client_secret']; // Client Secret (generated at Azure)
 * $redirect_uri = $wp_simple_azure_ad_login_options['redirect_uri']; // Redirect URI (your logon page address)
 * $email_errors = $wp_simple_azure_ad_login_options['email_errors']; // Send errors to administrator\'s email address
 */
Settings page

It is likely that I will not develop this plugin any further, so unless someone else does it, you are likely better off with aad-sso-wordpress plugin I mentioned at the beginning of this post. The little amount what I tested my code, it seems to work and hopefully there are no bigger security issues with it (or compatibility issues). Related to Azure AD configurations needed, please see my earlier example, since the same instructions apply. Please drop me an email if you find some bigger considerations about my quickly handcrafted AAD plugin. And please do remember that you use this software at your own risk, I’m not taking any responsibility of the provided code.

You can also download my plugin as a zip file if you like.