XF 2.3 XenForo OAuth troubleshooting help needed MediaWiki

Bryan_D2

New member
I'm new to XenForo; our whole forum is moving to XenForo from vBulletin in the next few weeks. We have run into a problem with OAuth talking to MediaWiki. So, after a lot of debugging and troubleshooting, I tried a different solution, using WordPress as an OAuth client and XenForo as the OAuth provider. I end up with the same problem. Data is not being sent back to MediaWiki or WordPress. I also tested via curl and Postman; all results were the same.

I'm trying to find a few things.
1) Any logs related to OAuth on XenForo
2) Is there a further configuration outside of the OAuth that I need to enable on the XenForo side?
3) Is there a better way to troubleshoot XenForo Oauth as a provider?


Error message example
Invalid response received from Auth Provider. Contact your administrator for more details.
Response :
{ "message": "Sorry, we're currently unavailable. Please check back later." }

WordPress:
Error
Token Response Received = {

MediaWiki
For MediaWiki I am using the Extensions PluggableAuth and WSOAuth. For WSOAuth I had to build a custom XenForo provider. If I can get this all working, I hope to share it with the world.
Error
The username provided by the OAuth provider is not valid.
Further logging resulting in this
*Token URI during construction: NULL
*Failed to decode token response: Syntax error
*Raw Token Response: false

Thanks in advance for any help or ideas you can share.
 
Update: I tried creating a new VM, a basic new installation of XenForo 2.3.6, no database import, and a new MediaWiki, and got the same result. Is there a place to find logs of OAuth activity in XenForo?
 
Board is now active.
Re-testing, and I keep getting a response code 400 and an invalid grant.

I tried to dig into the API docs, but the two of the three entries into OAuth2 are "Unknown, documentation incomplete" https://xenforo.com/community/pages/api-endpoints/#route_post_oauth2_revoke

[2025-04-22 01:40:48] XenForoOAuthProvider Initialized.
[2025-04-22 01:40:48] Auth URI: https://test.net/xenforo/index.php?oauth2/authorize
[2025-04-22 01:40:48] Token URI: https://test.net/xenforo/index.php?api/oauth2/token
[2025-04-22 01:40:48] User Info URI: https://test.net/xenforo/api/me
[2025-04-22 01:40:48] Redirect URI: https://test.net/wiki/index.php/Special:PluggableAuthLogin
[2025-04-22 01:40:48] Auth URI (constructor): https://test.net/xenforo/index.php?oauth2/authorize
[2025-04-22 01:40:48] Token URI (constructor): https://test.net/xenforo/index.php?api/oauth2/token
[2025-04-22 01:40:48] getUser called. Code received (via $key): oauth_key_placeholder
[2025-04-22 01:40:48] Attempting to exchange code for token.
[2025-04-22 01:40:48] POST Request to: https://test.net/xenforo/index.php?api/oauth2/token
[2025-04-22 01:40:48] POST Data: grant_type=authorization_code&code=oauth_key_placeholder&redirect_uri=https%3A%2F%2Ftest.net%2Fwiki%2Finde>
[2025-04-22 01:40:48] POST Response Code: 400
[2025-04-22 01:40:48] HTTP Error (POST): 400 Response: {
"errors": [
{
"code": "invalid_grant",
"message": "The provided authorization code or refresh token is invalid, expired, revoked, does not match the redirection URI used in th>
"params": []
}
]
}
[2025-04-22 01:40:48] Error: Failed to communicate with the token endpoint.
 
[2025-04-22 01:40:48] getUser called. Code received (via $key): oauth_key_placeholder
[2025-04-22 01:40:48] Attempting to exchange code for token.
[2025-04-22 01:40:48] POST Request to: https://test.net/xenforo/index.php?api/oauth2/token
[2025-04-22 01:40:48] POST Data: grant_type=authorization_code&code=oauth_key_placeholder&redirect_uri=https%3A%2F%2Ftest.net%2Fwiki%2Finde>
I am not familiar with WSOAuth, but this doesn't look right - it seems like a placeholder is used instead of the real auth code.

Can you post the code for your XenForo provider?
 
Last edited:
I am not familiar with WSOAuth, but this doesn't look right - it seems like a placeholder is used instead of the real auth code.

Can you post the code for your XenForo provider?
Sure! The way it should work is Extension PluggableAuth is the frame work for all the various methods and WSOAuth (Wikibase Solutions OAuth) Allows you to create new providers. These are placed into the WSOAuth Extension folder. (extensions/WSOAuth/src/AuthenticationProvider)

LocalSettings also needs to get set up; I can drop some of that info in, too. If needed.


PHP:
<?php
namespace MediaWiki\Extension\WSOAuth\AuthenticationProvider;

use WSOAuth\AuthenticationProvider\AuthProvider;
use MediaWiki\User\UserIdentity as MWUserIdentity;
use MediaWiki\User\UserNameUtils;
use MediaWiki\MediaWikiServices; // Needed for UserFactory

class XenForoOAuthProvider extends AuthProvider {
    private $authUri;
    private $tokenUri;
    private $userInfoUri; // Added for flexibility
    private $clientId;
    private $clientSecret;
    private $redirectUri;
    private $scopes = 'user:read'; // Default scope

    private $logFilePath = '/var/www/public_html/wiki/wsoauth.log'; // Define the log file path

    private function writeToLog(string $message): void {
        $timestamp = date('Y-m-d H:i:s');
        $logMessage = "[{$timestamp}] {$message}" . PHP_EOL;
        // Ensure directory exists and is writable (add more robust checks if needed)
        if (is_writable(dirname($this->logFilePath))) {
             file_put_contents($this->logFilePath, $logMessage, FILE_APPEND);
        } else {
             error_log("WSOAuth Log Error: Directory or file not writable: " . $this->logFilePath);
        }
    }

    public function __construct(
        string $clientId,
        string $clientSecret,
        ?string $authUri,
        ?string $redirectUri,
        array $extensionData = []
    ) {
        parent::__construct($clientId, $clientSecret, $authUri, $redirectUri, $extensionData);
          $this->tokenUri = $extensionData['tokenUri'] ?? null;
          $this->authUri = $authUri;
        //forced AuthURI $this->authUri = $authUri ?? $extensionData['authUri'] ?? 'https://teststar2.astromech.net/xenforo/index.php?oauth2/authorize';
        //forced TokenURI $this->tokenUri = $extensionData['tokenUri'] ?? 'https://teststar2.astromech.net/xenforo/index.php?api/oauth2/token';
        // Make user info endpoint configurable
        $this->userInfoUri = $extensionData['userInfoUri'] ?? 'https://teststar2.astromech.net/xenforo/api/me';
        // Make scopes configurable if needed
        $this->scopes = $extensionData['scopes'] ?? 'user:read';

        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
        $this->redirectUri = $redirectUri;

        $this->writeToLog("XenForoOAuthProvider Initialized.");
        $this->writeToLog("Auth URI: " . $this->authUri);
        $this->writeToLog("Token URI: " . $this->tokenUri);
        $this->writeToLog("User Info URI: " . $this->userInfoUri);
        $this->writeToLog("Redirect URI: " . $this->redirectUri);
        $this->writeToLog("Auth URI (constructor): " . ($this->authUri ?? 'not set'));
        $this->writeToLog("Token URI (constructor): " . ($this->tokenUri ?? 'not set'));


        // Avoid logging secrets if possible, or be very careful with log security
        // $this->writeToLog("Client ID: " . $this->clientId);

        if (!file_exists($this->logFilePath)) {
            // Attempt to create the file; requires write permission in the directory
             @touch($this->logFilePath);
        }
        if (!is_writable($this->logFilePath)) {
            error_log("WSOAuth: Log file is not writable: " . $this->logFilePath);
        }
    }

    // *** postRequest and getRequest remain the same, but ensure robust error checking ***
    private function postRequest($url, $data) {
        $this->writeToLog("POST Request to: " . $url);
        $this->writeToLog("POST Data: " . http_build_query($data)); // Log data being sent

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data)); // Send as application/x-www-form-urlencoded
         // Consider adding User-Agent
        curl_setopt($ch, CURLOPT_USERAGENT, 'MediaWiki-WSOAuth-Client');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // Ensure cert is valid
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // Ensure hostname matches cert
        curl_setopt($ch, CURLOPT_TIMEOUT, 15); // Slightly longer timeout?

        $response = curl_exec($ch);
        $error = curl_error($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $this->writeToLog("POST Response Code: " . $httpCode);
        // Only log sensitive parts of the response if absolutely necessary for debugging
        // $this->writeToLog("POST Raw Response: " . $response);

        if ($response === false) {
            $this->writeToLog("cURL Error (POST): " . $error);
            error_log("WSOAuth cURL Error (POST) to " . $url . ": " . $error);
            return false;
        }

        // Allow successful 2xx codes, not just 200, though Xenforo likely uses 200
        if ($httpCode < 200 || $httpCode >= 300) {
            $this->writeToLog("HTTP Error (POST): " . $httpCode . " Response: " . $response);
            error_log("WSOAuth HTTP Error (POST) " . $httpCode . " from " . $url . ": " . $response);
            return false;
        }

        return $response;
    }

    private function getRequest($url, $headers = []) {
        $this->writeToLog("GET Request to: " . $url);
        $this->writeToLog("GET Headers: " . implode(', ', $headers));

        $ch = curl_init($url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
         // Consider adding User-Agent
        curl_setopt($ch, CURLOPT_USERAGENT, 'MediaWiki-WSOAuth-Client');
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // Ensure cert is valid
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // Ensure hostname matches cert
        curl_setopt($ch, CURLOPT_TIMEOUT, 15); // Slightly longer timeout?

        $response = curl_exec($ch);
        $error = curl_error($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        $this->writeToLog("GET Response Code: " . $httpCode);
        // $this->writeToLog("GET Raw Response: " . $response);

        if ($response === false) {
            $this->writeToLog("cURL Error (GET): " . $error);
            error_log("WSOAuth cURL Error (GET) to " . $url . ": " . $error);
            return false;
        }

        // Allow successful 2xx codes
        if ($httpCode < 200 || $httpCode >= 300) {
            $this->writeToLog("HTTP Error (GET): " . $httpCode . " Response: " . $response);
            error_log("WSOAuth HTTP Error (GET) " . $httpCode . " from " . $url . ": " . $response);
            return false;
        }

        return $response;
    }


    /**
     * Called after the user returns from the provider with an authorization code.
     * WSOAuth passes the 'code' parameter as $key and potentially state/other info as $secret.
     * Signature MUST match the parent AuthProvider::getUser method.
     */
    public function getUser(string $key, string $secret, &$errorMessage): ?MWUserIdentity {
        // Treat the first parameter ($key) as the authorization code
        $code = $key;
        // The $secret parameter might contain state or other info depending on WSOAuth version/config.
        // For standard OAuth2 code exchange, we might not use $secret directly here.
        $this->writeToLog("getUser called. Code received (via \$key): " . $code);
        $this->writeToLog("getUser called. \$secret parameter value: " . $secret);

        $this->writeToLog("Attempting to exchange code for token.");

        // ** CORRECTED Token Request Data **
        $tokenRequestData = [
            'grant_type' => 'authorization_code',
            'code' => $code, // Use the code from the $key parameter
            'redirect_uri' => $this->redirectUri,
            'client_id' => $this->clientId,
            'client_secret' => $this->clientSecret
            // 'scope' => $this->scopes // Check if XenForo needs scope here
        ];

        $rawTokenData = $this->postRequest($this->tokenUri, $tokenRequestData);

        // ... rest of the getUser method remains the same ...
        // ... (handle $rawTokenData, $tokenData, $userInfoResponse, $userInfo, $userId, $username, return UserIdentity or null)

        if ($rawTokenData === false) {
            $errorMessage = 'Failed to communicate with the token endpoint.';
            $this->writeToLog("Error: " . $errorMessage);
            return null;
        }

        $tokenData = json_decode($rawTokenData, true);
        if (json_last_error() !== JSON_ERROR_NONE || !isset($tokenData['access_token'])) {
             $this->writeToLog("Failed to decode token response or access_token missing. Error: " . json_last_error_msg());
             $this->writeToLog("Raw Token Response: " . $rawTokenData);
            $errorMessage = 'Failed to retrieve a valid access token.';
             if (isset($tokenData['error_description'])) {
                 $errorMessage .= ' Provider message: ' . $tokenData['error_description'];
             } elseif (isset($tokenData['error'])) {
                 $errorMessage .= ' Provider error: ' . $tokenData['error'];
             } elseif (isset($tokenData['message'])) {
                 $errorMessage .= ' Provider message: ' . $tokenData['message'];
             }
            return null;
        }


        $accessToken = $tokenData['access_token'];
        $this->writeToLog("Access token obtained successfully.");

        // Fetch user information
        $userInfoResponse = $this->getRequest($this->userInfoUri, [
            'Authorization: Bearer ' . $accessToken,
            'Accept: application/json'
        ]);
        // Log the raw user information response for debugging
        $this->writeToLog("Raw UserInfo Response: " . $userInfoResponse);


         if ($userInfoResponse === false) {
             $errorMessage = 'Failed to communicate with the user info endpoint.';
             $this->writeToLog("Error: " . $errorMessage);
             return null;
         }

         $userInfo = json_decode($userInfoResponse, true);

         //  /api/me response
         if (json_last_error() !== JSON_ERROR_NONE || !isset($userInfo['user']) || !isset($userInfo['user']['user_id']) || !isset($userInfo['user']['username'])) {
             $this->writeToLog("User info decode error or missing required fields (user, user.user_id, user.username). Error: " . json_last_error_msg());
             $this->writeToLog("Raw UserInfo Response: " . $userInfoResponse);
             $errorMessage = 'Failed to retrieve valid user information structure (user_id, username).';
             return null;
         }

         $xenforoUser = $userInfo['user'];
         $userId = $xenforoUser['user_id'];
         $username = $xenforoUser['username'];
       
         $this->writeToLog("User info retrieved: ID=" . $userId . ", Username=" . $username);
         $this->writeToLog("Original XenForo Username: " . $username);

$userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
$mwUsername = trim($username);
$mwUsername = str_replace(' ', '_', $mwUsername);

if (!$userNameUtils->isValid($mwUsername)) {
    $this->writeToLog("Cleaned Username '" . $mwUsername . "' is invalid, using fallback.");
    $mwUsername = 'XFUser_' . $userId;
    if (!$userNameUtils->isValid($mwUsername)) {
        $mwUsername = 'XFUser_' . uniqid();
        $this->writeToLog("Using uniqid fallback: " . $mwUsername);
    }
} else {
    $mwUsername = $userNameUtils->getCanonical($mwUsername);
}

$this->writeToLog("Final MediaWiki Username: " . $mwUsername);


         // Validate and sanitize username for MediaWiki
         $userNameUtils = MediaWikiServices::getInstance()->getUserNameUtils();
         $mwUsername = trim($username);
         $mwUsername = str_replace(' ', '_', $mwUsername);

         if (!$userNameUtils->isValid($mwUsername)) {
             $this->writeToLog("Original username '" . $username . "' ('" . $mwUsername . "') is invalid for MediaWiki. Generating fallback.");
             $mwUsername = 'XFUser_' . $userId;.














           
             if (!$userNameUtils->isValid($mwUsername)) {
                 $mwUsername = 'XFUser_' . uniqid();
                 $this->writeToLog("Primary fallback invalid, using uniqid fallback: " . $mwUsername);
             }
         } else {
             $mwUsername = $userNameUtils->getCanonical($mwUsername); // Canonicalize valid username
         }
         $this->writeToLog("Using MediaWiki Username: " . $mwUsername);

         // Check if parent requires a specific return type if ?MWUserIdentity still causes issues
         return new MWUserIdentity( (string)$userId, $mwUsername ); // Ensure ID is string if needed by MW/PluggableAuth
    }


    /**
     * Initiates the login process by generating the authorization URL.
     */
    public function login(?string &$key, ?string &$secret, ?string &$authUrl): bool {
        $this->writeToLog("login called. Generating authorization URL.");
   
          // Validate that authUri is set
        if (empty($this->authUri)) {
        $this->writeToLog("Error: authUri is not set. Cannot generate authorization URL.");
        return false;
        }


        $params = [
            'response_type' => 'code',
            'client_id' => $this->clientId,
            'redirect_uri' => $this->redirectUri,
            'scope' => $this->scopes, // Use configured scope(s)
            // 'state' => $generatedState // Add if handling state manually
        ];

        $authUrl = $this->authUri . '?' . http_build_query($params);
        $this->writeToLog("Authorization URL generated: " . $authUrl);

        //  assignments for $key and $secret **
        $key = 'oauth_key_placeholder'; // REMOVE?
        $secret = 'oauth_secret_placeholder'; // REMOVE?
        $this->writeToLog("login output: Key=" . $key . ", Secret=" . $secret);
        $this->writeToLog("Authorization code dynamically set: " . $key);



        return true; // Indicates the auth URL is ready
    }

    public function logout(MWUserIdentity &$user): void {
       
        $this->writeToLog("logout called for user ID: " . $user->getId());
        return;
    }

    public function saveExtraAttributes(int $id): void {
        // This method is called by PluggableAuth after authentication.
        // $id is the MediaWiki user ID.
        // Fetch details (like email, real name) using the access token (if stored)
        // or perhaps stored during getUser, and update the MediaWiki user.
        // Requires access to the access token or the user info retrieved earlier.
        // WSOAuth might provide ways to store/retrieve this data.
        $this->writeToLog("saveExtraAttributes called for MediaWiki user ID: " . $id . ". (Not Implemented)");

     
    }
}
 
Hmm ... your code confuses me a bit.

PHP:
/**
 * Called after the user returns from the provider with an authorization code.
 * WSOAuth passes the 'code' parameter as $key and potentially state/other info as $secret.
 * Signature MUST match the parent AuthProvider::getUser method.
 */
public function getUser(string $key, string $secret, &$errorMessage): ?MWUserIdentity {

Where did you get that form?

The signature in GitHub and the developer wiki seems different:
PHP:
abstract public function getUser( string $key, string $secret, &$errorMessage );

IMHO you'd need to generate $secret in login() and pass this as state to XenForo.

In getUser() you'd have to get the code from $_GET['code'] , check state and get the access token using the code.

This guy got it working in a unique way
That's for XF1 with an additional Add-on on XenForo side, won't work with XF2.
 
Last edited:
Hmm ... your code confuses me a bit.

PHP:
/**
 * Called after the user returns from the provider with an authorization code.
 * WSOAuth passes the 'code' parameter as $key and potentially state/other info as $secret.
 * Signature MUST match the parent AuthProvider::getUser method.
 */
public function getUser(string $key, string $secret, &$errorMessage): ?MWUserIdentity {

Where did you get that form?

The signature in GitHub and the developer wiki seems different:
PHP:
abstract public function getUser( string $key, string $secret, &$errorMessage );

IMHO you'd need to generate $secret in login() and pass this as state to XenForo.

In getUser() you'd have to get the code from $_GET['code'] , check state and get the access token using the code.


That's for XF1 with an additional Add-on on XenForo side, won't work with XF2.
Thank you for taking a look. Coding is not my day job, so trying to piece it together as I go. Good catch on the Abstract public vs just public function. Will correct that and see how far I get. Thanks again!
 
Here is the modified file:
  • Replaced direct cURL calls with Guzzle
  • Replaced custom logger with PSR logger
  • Fixed various issues (return types, namespace, etc.)
  • Added code to generate and verify state
  • Get authorization code from $_GET
As said, this does work (at least for me) with latest XF, MW and WSOAuth.

The file must be placed in extensions/WSOAuth/src/AuthenticationProvider.

LocalSettings.php config
PHP:
$wgGroupPermissions['*']['autocreateaccount'] = true;

wfLoadExtension('PluggableAuth');
wfLoadExtension('WSOAuth');

$wgPluggableAuth_Config['xenforo'] = [
    'plugin' => 'WSOAuth',
    'data' => [
        'type' => 'xenforo',
        'uri' => 'https://xenforo.url/oauth2/authorize',
        'extensionData' => [
            'tokenUri' => 'https://xenforo.url/api/oauth2/token',
            'userInfoUri' => 'https://xenforo.url/api/me',
        ],
        'redirectUri' => 'https://mediawiki.url/Special:PluggableAuthLogin',
        'clientId' => '...',
        'clientSecret' => '...'
    ],
    'buttonLabelMessage' => 'Login with XenForo'
];

$wgOAuthCustomAuthProviders = [
    'xenforo' => \WSOAuth\AuthenticationProvider\XenForoAuth::class
];

$wgDebugLogGroups = [
    'WSOAuth' => '/path/to/wsoauth-xenforo.log'
];

Before this is used in production it should undergo another round of refactoring to make this somewhat robust (and use modern code).

HTH
 

Attachments

Last edited:
Here is the modified file:
  • Replaced direct cURL calls with Guzzle
  • Replaced custom logger with PSR logger
  • Fixed various issues (return types, namespace, etc.)
  • Added code to generate and verify state
  • Get authorization code from $_GET
As said, this does work (at least for me) with latest XF, MW and WSOAuth.

The file must be placed in extensions/WSOAuth/src/AuthenticationProvider.

LocalSettings.php config
PHP:
$wgGroupPermissions['*']['autocreateaccount'] = true;

wfLoadExtension('PluggableAuth');
wfLoadExtension('WSOAuth');

$wgPluggableAuth_Config['xenforo'] = [
    'plugin' => 'WSOAuth',
    'data' => [
        'type' => 'xenforo',
        'uri' => 'https://xenforo.url/oauth2/authorize',
        'extensionData' => [
            'tokenUri' => 'https://xenforo.url/api/oauth2/token',
            'userInfoUri' => 'https://xenforo.url/api/me',
        ],
        'redirectUri' => 'https://mediawiki.url/Special:PluggableAuthLogin',
        'clientId' => '...',
        'clientSecret' => '...'
    ],
    'buttonLabelMessage' => 'Login with XenForo'
];

$wgOAuthCustomAuthProviders = [
    'xenforo' => \WSOAuth\AuthenticationProvider\XenForoAuth::class
];

$wgDebugLogGroups = [
    'WSOAuth' => '/var/www/html/mediawiki/log/wsoauth-xenforo-log'
];

Before this is used in production it should undergo another round of refactoring to make this somewhat robust (and use modern code).

HTH
THANK YOU, going to test this out now.
 
Back
Top Bottom