XF 2.3 Microsoft 365 (business) SSO addon

Case

Well-known member
I'm not a developer but using a bit of my own knowledge and ChatGPT (I know, I know) I've created a new Connected Account addon to allow users to login using a Microsoft 365 Business account.

The addon works as far as you are redirected to MS, login and then redirected back to the /connected_accounts.php file. Here is where I get an "There is no valid connected account request available. Please try again." error. In the Entra ID app created on MS I can see the logins being recorded in the app logs, so that part of it seems to be working correctly. It's just when you are passed back to the forum.

My Addon structure, aside from the usual _data & _output folders...

ConnectedAccount
-OAuthService
--MicrosoftBusinessService.php
-Provider
--MicrosoftBusiness.php
-ProviderData
--MicrosoftBusiness.php

File contents:
OAuthService
--MicrosoftBusinessService.php
PHP:
<?php

namespace Alliance\MSLogin\ConnectedAccount\Provider;

use XF\ConnectedAccount\Provider\AbstractProvider;
use XF\Entity\ConnectedAccountProvider;
use XF\Mvc\Controller;

class MicrosoftBusiness extends AbstractProvider
{
    public function getProviderDataClass(): string
    {
        return 'Alliance\MSLogin\ConnectedAccount\ProviderData\MicrosoftBusiness';
    }

    public function getTitle()
    {
        return 'Microsoft 365 Business';
    }

    public function getDefaultOptions()
    {
        return [
            'client_id' => '',
            'client_secret' => '',
            'redirect_uri' => ''
        ];
    }

    public function getOAuthServiceName()
    {
        return 'MicrosoftBusinessOAuth'; // Ensure a valid service name is returned
    }


    /**
     * Ensure that a valid state is always passed and saved to avoid null issues.
     */
    public function handleAuthorization(Controller $controller, ConnectedAccountProvider $provider, $returnUrl)
    {
        // Log the session ID (session should already be started by XenForo)
        \XF::logError('Session ID: ' . session_id());

        // Explicitly create and set a valid state if none exists
        $state = $controller->filter('state', 'str');
        if (!$state) {
            $state = bin2hex(random_bytes(16)); // Generate a random state if none is present
            \XF::logError('Setting OAuth state: ' . $state);
        }

        // Store the state in XenForo's session to keep track of it
        \XF::session()->set('oauth_state', $state);

        // Store the connected account request in the session
        $sessionRequest = [
            'provider' => $provider->provider_id,
            'returnUrl' => $returnUrl,
            'test' => false  // Or true if in test mode
        ];
        
        \XF::session()->set('connectedAccountRequest', $sessionRequest);
        \XF::session()->save();
        \XF::logError('connectedAccountRequest set: ' . print_r($sessionRequest, true));

        // Get the service to handle authorization
        $service = new \Alliance\MSLogin\ConnectedAccount\OAuthService\MicrosoftBusinessService(
            $provider->options['client_id'],
            $provider->options['client_secret'],
            $this->getRedirectUri($provider)
        );

        // Redirect the user to Microsoft's OAuth2 authorization URL with the valid state
        return $controller->redirect($service->getAuthorizationUrl($state));
    }

    public function getOAuthConfig(ConnectedAccountProvider $provider, $redirectUri = null)
    {
        return [
            'key' => $provider->options['client_id'],
            'secret' => $provider->options['client_secret'],
            'scopes' => ['openid', 'profile', 'email', 'offline_access', 'https://graph.microsoft.com/User.Read'],
            'redirect' => $redirectUri ?: $this->getRedirectUri($provider),
        ];
    }

    public function handleCallback(Controller $controller, ConnectedAccountProvider $provider)
{
    \XF::logError('Callback reached');
    \XF::logError('Session ID: ' . session_id());

    // Retrieve the OAuth state from the session
    $storedState = \XF::session()->get('oauth_state');
    $returnedState = $controller->filter('state', 'str'); // Now we retrieve the returned state properly

    // Log the states for debugging
    \XF::logError('Stored OAuth state: ' . $storedState);
    \XF::logError('Returned OAuth state: ' . $returnedState);

    // Get the authorization code from the request
    $authorizationCode = $controller->filter('code', 'str');
    \XF::logError('Returned code: ' . $authorizationCode);

    // Validate the state to prevent CSRF attacks
    if ($storedState !== $returnedState) {
        throw new \XF\Mvc\Reply\Exception('Invalid OAuth state detected. Please try again.');
    }

    // Proceed to exchange the authorization code for an access token
    $service = new \Alliance\MSLogin\ConnectedAccount\OAuthService\MicrosoftBusinessService(
        $provider->options['client_id'],
        $provider->options['client_secret'],
        $this->getRedirectUri($provider)
    );

    $tokenResponse = $service->getAccessToken($authorizationCode);

    // Check if the token was retrieved successfully
    if (empty($tokenResponse['access_token'])) {
        throw new \XF\Mvc\Reply\Exception('Failed to retrieve access token from Microsoft.');
    }

    // Fetch user data using the access token
    $userData = $service->fetchUserData($tokenResponse['access_token']);

    if (empty($userData['id'])) {
        throw new \XF\Mvc\Reply\Exception('Failed to retrieve user data from Microsoft.');
    }

    // Return the user data as an object that can be used to link the connected account
    $providerDataClass = $this->getProviderDataClass();
    /** @var \XF\ConnectedAccount\ProviderData\AbstractProviderData $providerData */
    $providerData = new $providerDataClass($provider, $userData);

    return $providerData;
}


}

Provider
--MicrosoftBusiness.php
PHP:
<?php
namespace Alliance\MSLogin\ConnectedAccount\Provider;
use XF\ConnectedAccount\Provider\AbstractProvider;
use XF\Entity\ConnectedAccountProvider;
use XF\Mvc\Controller;
class MicrosoftBusiness extends AbstractProvider
{
    public function getProviderDataClass(): string
    {
        return 'Alliance\MSLogin\ConnectedAccount\ProviderData\MicrosoftBusiness';
    }
    public function getTitle()
    {
        return 'Microsoft 365 Business';
    }
    public function getDefaultOptions()
    {
        return [
            'client_id' => '',
            'client_secret' => '',
            'redirect_uri' => ''
        ];
    }
    public function getOAuthServiceName()
    {
        return 'MicrosoftBusinessOAuth'; // Ensure a valid service name is returned
    }

    /**
     * Ensure that a valid state is always passed and saved to avoid null issues.
     */
    public function handleAuthorization(Controller $controller, ConnectedAccountProvider $provider, $returnUrl)
    {
        // Log the session ID (session should already be started by XenForo)
        \XF::logError('Session ID: ' . session_id());
        // Explicitly create and set a valid state if none exists
        $state = $controller->filter('state', 'str');
        if (!$state) {
            $state = bin2hex(random_bytes(16)); // Generate a random state if none is present
            \XF::logError('Setting OAuth state: ' . $state);
        }
        // Store the state in XenForo's session to keep track of it
        \XF::session()->set('oauth_state', $state);
        // Store the connected account request in the session
        $sessionRequest = [
            'provider' => $provider->provider_id,
            'returnUrl' => $returnUrl,
            'test' => false  // Or true if in test mode
        ];
        
        \XF::session()->set('connectedAccountRequest', $sessionRequest);
        \XF::session()->save();
        \XF::logError('connectedAccountRequest set: ' . print_r($sessionRequest, true));
        // Get the service to handle authorization
        $service = new \Alliance\MSLogin\ConnectedAccount\OAuthService\MicrosoftBusinessService(
            $provider->options['client_id'],
            $provider->options['client_secret'],
            $this->getRedirectUri($provider)
        );
        // Redirect the user to Microsoft's OAuth2 authorization URL with the valid state
        return $controller->redirect($service->getAuthorizationUrl($state));
    }
    public function getOAuthConfig(ConnectedAccountProvider $provider, $redirectUri = null)
    {
        return [
            'key' => $provider->options['client_id'],
            'secret' => $provider->options['client_secret'],
            'scopes' => ['openid', 'profile', 'email', 'offline_access', 'https://graph.microsoft.com/User.Read'],
            'redirect' => $redirectUri ?: $this->getRedirectUri($provider),
        ];
    }
    public function handleCallback(Controller $controller, ConnectedAccountProvider $provider)
{
    \XF::logError('Callback reached');
    \XF::logError('Session ID: ' . session_id());
    // Retrieve the OAuth state from the session
    $storedState = \XF::session()->get('oauth_state');
    $returnedState = $controller->filter('state', 'str'); // Now we retrieve the returned state properly
    // Log the states for debugging
    \XF::logError('Stored OAuth state: ' . $storedState);
    \XF::logError('Returned OAuth state: ' . $returnedState);
    // Get the authorization code from the request
    $authorizationCode = $controller->filter('code', 'str');
    \XF::logError('Returned code: ' . $authorizationCode);
    // Validate the state to prevent CSRF attacks
    if ($storedState !== $returnedState) {
        throw new \XF\Mvc\Reply\Exception('Invalid OAuth state detected. Please try again.');
    }
    // Proceed to exchange the authorization code for an access token
    $service = new \Alliance\MSLogin\ConnectedAccount\OAuthService\MicrosoftBusinessService(
        $provider->options['client_id'],
        $provider->options['client_secret'],
        $this->getRedirectUri($provider)
    );
    $tokenResponse = $service->getAccessToken($authorizationCode);
    // Check if the token was retrieved successfully
    if (empty($tokenResponse['access_token'])) {
        throw new \XF\Mvc\Reply\Exception('Failed to retrieve access token from Microsoft.');
    }
    // Fetch user data using the access token
    $userData = $service->fetchUserData($tokenResponse['access_token']);
    if (empty($userData['id'])) {
        throw new \XF\Mvc\Reply\Exception('Failed to retrieve user data from Microsoft.');
    }
    // Return the user data as an object that can be used to link the connected account
    $providerDataClass = $this->getProviderDataClass();
    /** @var \XF\ConnectedAccount\ProviderData\AbstractProviderData $providerData */
    $providerData = new $providerDataClass($provider, $userData);
    return $providerData;
}

}

ProviderData
--MicrosoftBusiness.php
PHP:
<?php
namespace Alliance\MSLogin\ConnectedAccount\ProviderData;
use XF\ConnectedAccount\ProviderData\AbstractProviderData;
class MicrosoftBusiness extends AbstractProviderData
{
    public function getDefaultEndpoint()
    {
        return 'https://graph.microsoft.com/v1.0/me';
    }
    public function getProviderKey()
    {
        return $this->data['id'] ?? null;
    }
    public function getUsername()
    {
        return $this->data['displayName'] ?? null;
    }
    public function getEmail()
    {
        return $this->data['mail'] ?? $this->data['userPrincipalName'] ?? null;
    }
    public function getProfileLink()
    {
        return null; // Not needed for Microsoft
    }
    public function getAvatarUrl()
    {
        return null; // Microsoft does not provide an easy URL for avatars
    }
}

Anyone know where its going wrong?

Addon attached, if anyone wants to have a play.
 

Attachments

Back
Top Bottom