Skip to main content

WPStack

OAuth 2.0 for WordPress REST Integrations: When and How to Use It

OAuth 2.0 for WordPress REST Integrations: When and How to Use It
October 15, 2026
No Comments

WordPress should become an OAuth authorization server only when the product truly needs delegated third-party access. For ordinary server-to-server administration, Application Passwords over HTTPS are simpler; for delegated access, use a maintained OAuth implementation with Authorization Code and PKCE rather than inventing a token server in a plugin.

1. Comparing WordPress API Authentication Architectures

Before implementing an OAuth2 authorization server, it is essential to evaluate how it compares against legacy WordPress authentication mechanisms across critical enterprise security vectors.

2. Anatomy of the OAuth 2.0 Authorization Code Flow with PKCE

Proof Key for Code Exchange (PKCE) extends the standard Authorization Code grant to protect public clients (such as mobile apps and JavaScript SPAs) where client secrets cannot be securely stored. The cryptographic protocol executes in five discrete phases:

  1. Code Verifier & Challenge Generation: The client generates a high-entropy cryptographic random string called the code_verifier (43 to 128 characters) and computes its SHA-256 hash, base64url-encoded, called the code_challenge.
  2. Authorization Request: The client redirects the user to the WordPress authorization endpoint (/oauth/authorize) passing client_id, redirect_uri, response_type=code, scope, code_challenge, and code_challenge_method=S256.
  3. User Consent & Code Issuance: The user logs into WordPress, reviews the requested permissions, and clicks “Authorize”. WordPress generates a short-lived authorization code (valid for 5 minutes) associated with the hashed challenge and redirects back to the client’s redirect URI.
  4. Token Exchange with Verifier: The client exchanges the authorization code for an access token via a POST request to /oauth/token, transmitting the plaintext code_verifier.
  5. Cryptographic Verification & Token Dispatch: WordPress hashes the received code_verifier with SHA-256 and compares it against the stored code_challenge. Upon match, it issues a 60-minute Bearer Access Token and a 30-day Refresh Token.

3. Database Schema for the Custom OAuth2 Server

To support high-throughput token issuance, scope lookups, and instantaneous session revocation, we define four optimized relational tables with covering indexes.

-- 1. Registered OAuth2 Applications
CREATE TABLE `wp_oauth_clients` (
  `client_id` varchar(64) NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `name` varchar(255) NOT NULL,
  `secret_hash` varchar(255) DEFAULT NULL,
  `redirect_uris` text NOT NULL,
  `allowed_scopes` text NOT NULL,
  `is_confidential` tinyint(1) unsigned NOT NULL DEFAULT 0,
  `created_at` datetime NOT NULL,
  PRIMARY KEY (`client_id`),
  KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

-- 2. Short-Lived Authorization Codes (5 min lifespan)
CREATE TABLE `wp_oauth_auth_codes` (
  `code_hash` char(64) NOT NULL,
  `client_id` varchar(64) NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `redirect_uri` varchar(500) NOT NULL,
  `code_challenge` varchar(128) NOT NULL,
  `code_challenge_method` varchar(10) NOT NULL DEFAULT 'S256',
  `scopes` text NOT NULL,
  `expires_at` int(11) unsigned NOT NULL,
  `is_used` tinyint(1) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`code_hash`),
  KEY `idx_expires_used` (`expires_at`, `is_used`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

-- 3. Bearer Access Tokens (60 min lifespan)
CREATE TABLE `wp_oauth_access_tokens` (
  `token_hash` char(64) NOT NULL,
  `client_id` varchar(64) NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `scopes` text NOT NULL,
  `expires_at` int(11) unsigned NOT NULL,
  `is_revoked` tinyint(1) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`token_hash`),
  KEY `idx_client_user` (`client_id`, `user_id`),
  KEY `idx_expires_revoked` (`expires_at`, `is_revoked`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

-- 4. Refresh Tokens for Seamless Rotation (30 day lifespan)
CREATE TABLE `wp_oauth_refresh_tokens` (
  `refresh_token_hash` char(64) NOT NULL,
  `access_token_hash` char(64) NOT NULL,
  `client_id` varchar(64) NOT NULL,
  `user_id` bigint(20) unsigned NOT NULL,
  `scopes` text NOT NULL,
  `expires_at` int(11) unsigned NOT NULL,
  `is_revoked` tinyint(1) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`refresh_token_hash`),
  KEY `idx_access_token` (`access_token_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;

4. Production PHP 8.3 Implementation: The OAuth2 Server Service

Below is the complete, strictly typed PSR-4 service class implementing PKCE code verification, token generation, and refresh token rotation.

<?php
declare(strict_types=1);

namespace WPStack\OAuth2\Services;

use wpdb;
use WP_Error;
use RuntimeException;
use InvalidArgumentException;

final class OAuth2ServerService
{
    private wpdb $db;
    private const AUTH_CODE_LIFESPAN = 300; // 5 Minutes
    private const ACCESS_TOKEN_LIFESPAN = 3600; // 1 Hour
    private const REFRESH_TOKEN_LIFESPAN = 2592000; // 30 Days

    public function __construct(wpdb $db)
    {
        $this->db = $db;
    }

    /**
     * Issues an ephemeral authorization code during the PKCE consent flow.
     */
    public function issueAuthorizationCode(
        string $clientId,
        int $userId,
        string $redirectUri,
        string $codeChallenge,
        string $codeChallengeMethod,
        array $scopes
    ): string {
        $rawCode = bin2hex(random_bytes(32));
        $codeHash = hash('sha256', $rawCode);
        $expiresAt = time() + self::AUTH_CODE_LIFESPAN;

        $table = $this->db->prefix . 'oauth_auth_codes';

        $this->db->insert(
            $table,
            [
                'code_hash'             => $codeHash,
                'client_id'             => $clientId,
                'user_id'               => $userId,
                'redirect_uri'          => $redirectUri,
                'code_challenge'        => $codeChallenge,
                'code_challenge_method' => $codeChallengeMethod,
                'scopes'                => implode(' ', $scopes),
                'expires_at'            => $expiresAt,
                'is_used'               => 0,
            ],
            ['%s', '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%d']
        );

        return $rawCode;
    }

    /**
     * Exchanges an authorization code and PKCE code_verifier for Access & Refresh tokens.
     *
     * @return array{access_token: string, token_type: string, expires_in: int, refresh_token: string, scope: string}|WP_Error
     */
    public function exchangeCodeForTokens(
        string $clientId,
        string $rawCode,
        string $redirectUri,
        string $codeVerifier
    ): array|WP_Error {
        $codeHash = hash('sha256', $rawCode);
        $table = $this->db->prefix . 'oauth_auth_codes';

        $codeRow = $this->db->get_row(
            $this->db->prepare(
                "SELECT * FROM {$table} WHERE code_hash = %s AND client_id = %s AND is_used = 0",
                $codeHash,
                $clientId
            )
        );

        if (!$codeRow) {
            return new WP_Error('invalid_grant', 'Invalid or expired authorization code.', ['status' => 400]);
        }

        if ((int)$codeRow->expires_at < time()) {
            return new WP_Error('invalid_grant', 'Authorization code has expired.', ['status' => 400]);
        }

        if ($codeRow->redirect_uri !== $redirectUri) {
            return new WP_Error('invalid_grant', 'Redirect URI mismatch.', ['status' => 400]);
        }

        // Validate PKCE Code Verifier
        if (!$this->verifyPkceChallenge($codeVerifier, $codeRow->code_challenge, $codeRow->code_challenge_method)) {
            return new WP_Error('invalid_grant', 'PKCE code verifier validation failed.', ['status' => 400]);
        }

        // Mark authorization code as used immediately to prevent replay attacks
        $this->db->update($table, ['is_used' => 1], ['code_hash' => $codeHash], ['%d'], ['%s']);

        // Issue Access Token & Refresh Token
        return $this->issueTokens((int)$codeRow->user_id, $clientId, explode(' ', $codeRow->scopes));
    }

    /**
     * Rotates refresh token and issues fresh access token.
     */
    public function refreshAccessToken(string $clientId, string $rawRefreshToken): array|WP_Error
    {
        $refreshHash = hash('sha256', $rawRefreshToken);
        $refreshTable = $this->db->prefix . 'oauth_refresh_tokens';

        $row = $this->db->get_row(
            $this->db->prepare(
                "SELECT * FROM {$refreshTable} WHERE refresh_token_hash = %s AND client_id = %s AND is_revoked = 0",
                $refreshHash,
                $clientId
            )
        );

        if (!$row || (int)$row->expires_at < time()) {
            return new WP_Error('invalid_grant', 'Invalid or expired refresh token.', ['status' => 400]);
        }

        // Revoke the old refresh token (Refresh Token Rotation pattern)
        $this->db->update($refreshTable, ['is_revoked' => 1], ['refresh_token_hash' => $refreshHash], ['%d'], ['%s']);

        // Revoke the associated old access token
        $this->db->update(
            $this->db->prefix . 'oauth_access_tokens',
            ['is_revoked' => 1],
            ['token_hash' => $row->access_token_hash],
            ['%d'],
            ['%s']
        );

        return $this->issueTokens((int)$row->user_id, $clientId, explode(' ', $row->scopes));
    }

    private function issueTokens(int $userId, string $clientId, array $scopes): array
    {
        $rawAccessToken = 'wpk_' . bin2hex(random_bytes(32));
        $accessTokenHash = hash('sha256', $rawAccessToken);
        $accessExpiresAt = time() + self::ACCESS_TOKEN_LIFESPAN;

        $rawRefreshToken = 'wpr_' . bin2hex(random_bytes(32));
        $refreshTokenHash = hash('sha256', $rawRefreshToken);
        $refreshExpiresAt = time() + self::REFRESH_TOKEN_LIFESPAN;

        $scopeString = implode(' ', $scopes);

        // Store Access Token
        $this->db->insert(
            $this->db->prefix . 'oauth_access_tokens',
            [
                'token_hash' => $accessTokenHash,
                'client_id'  => $clientId,
                'user_id'    => $userId,
                'scopes'     => $scopeString,
                'expires_at' => $accessExpiresAt,
                'is_revoked' => 0,
            ],
            ['%s', '%s', '%d', '%s', '%d', '%d']
        );

        // Store Refresh Token
        $this->db->insert(
            $this->db->prefix . 'oauth_refresh_tokens',
            [
                'refresh_token_hash' => $refreshTokenHash,
                'access_token_hash'  => $accessTokenHash,
                'client_id'          => $clientId,
                'user_id'            => $userId,
                'scopes'             => $scopeString,
                'expires_at'         => $refreshExpiresAt,
                'is_revoked'         => 0,
            ],
            ['%s', '%s', '%s', '%d', '%s', '%d', '%d']
        );

        return [
            'access_token'  => $rawAccessToken,
            'token_type'    => 'Bearer',
            'expires_in'    => self::ACCESS_TOKEN_LIFESPAN,
            'refresh_token' => $rawRefreshToken,
            'scope'         => $scopeString,
        ];
    }

    private function verifyPkceChallenge(string $verifier, string $challenge, string $method): bool
    {
        if ($method === 'plain') {
            return hash_equals($challenge, $verifier);
        }

        if ($method === 'S256') {
            $computedChallenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
            return hash_equals($challenge, $computedChallenge);
        }

        return false;
    }
}

5. The REST API Authentication & Scope Middleware

When an incoming HTTP request reaches the WordPress REST API with a Bearer token, the OAuth2AuthMiddleware intercepts the request, validates the token signature, sets the current WordPress user context, and verifies required scope capabilities.

<?php
declare(strict_types=1);

namespace WPStack\OAuth2\Middleware;

use wpdb;
use WP_Error;
use WP_REST_Request;
use WP_REST_Server;

final class OAuth2AuthMiddleware
{
    private wpdb $db;

    public function __construct(wpdb $db)
    {
        $this->db = $db;
    }

    public function registerHooks(): void
    {
        add_filter('rest_authentication_errors', [$this, 'authenticateBearerToken'], 20);
        add_filter('rest_pre_dispatch', [$this, 'enforceRouteScopes'], 10, 3);
    }

    /**
     * Intercepts REST authentication to authenticate valid OAuth2 Bearer tokens.
     */
    public function authenticateBearerToken(mixed $status): mixed
    {
        // If another authentication layer (like cookies) has already succeeded, respect it
        if ($status === true || (is_user_logged_in() && !isset($_SERVER['HTTP_AUTHORIZATION']))) {
            return $status;
        }

        $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
        if (empty($authHeader) || !str_starts_with(strtolower($authHeader), 'bearer ')) {
            return $status;
        }

        $rawToken = trim(substr($authHeader, 7));
        if (empty($rawToken)) {
            return new WP_Error('invalid_token', 'Bearer token missing.', ['status' => 401]);
        }

        $tokenHash = hash('sha256', $rawToken);
        $table = $this->db->prefix . 'oauth_access_tokens';

        // Check active token in database (or Redis cache)
        $tokenRow = $this->db->get_row(
            $this->db->prepare(
                "SELECT * FROM {$table} WHERE token_hash = %s AND is_revoked = 0",
                $tokenHash
            )
        );

        if (!$tokenRow) {
            return new WP_Error('invalid_token', 'Token not recognized or revoked.', ['status' => 401]);
        }

        if ((int)$tokenRow->expires_at < time()) {
            return new WP_Error('expired_token', 'Bearer token has expired.', ['status' => 401]);
        }

        // Set the active WordPress user context
        wp_set_current_user((int)$tokenRow->user_id);

        // Store active token scopes in global runtime request context
        $GLOBALS['wpstack_oauth_active_scopes'] = explode(' ', $tokenRow->scopes);
        $GLOBALS['wpstack_oauth_client_id'] = $tokenRow->client_id;

        return true;
    }

    /**
     * Enforces granular route-level scope requirements before dispatch.
     */
    public function enforceRouteScopes(mixed $result, WP_REST_Server $server, WP_REST_Request $request): mixed
    {
        $activeScopes = $GLOBALS['wpstack_oauth_active_scopes'] ?? null;
        if (!is_array($activeScopes)) {
            return $result; // Non-OAuth authenticated request
        }

        $route = $request->get_route();
        $method = $request->get_method();

        // Define Scope Mapping Rules for Sensitive Endpoints
        $requiredScope = match (true) {
            str_starts_with($route, '/wc/v3/orders') && $method === 'GET'    => 'orders:read',
            str_starts_with($route, '/wc/v3/orders') && $method !== 'GET'    => 'orders:write',
            str_starts_with($route, '/wc/v3/products') && $method !== 'GET'  => 'products:write',
            str_starts_with($route, '/wp/v2/users')                          => 'users:manage',
            default                                                          => null,
        };

        if ($requiredScope !== null && !in_array($requiredScope, $activeScopes, true)) {
            return new WP_Error(
                'insufficient_scope',
                sprintf('Token lacks required scope "%s" for this operation.', $requiredScope),
                ['status' => 403, 'required_scope' => $requiredScope]
            );
        }

        return $result;
    }
}

6. REST Token & Authorization Endpoints Implementation

We register official RFC 6749 endpoints under the namespace /wp-json/oauth/v2/ to handle authorization consent, token issuance, and token revocation.

<?php
declare(strict_types=1);

namespace WPStack\OAuth2\Controllers;

use WP_REST_Controller;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WPStack\OAuth2\Services\OAuth2ServerService;

final class OAuth2TokenRestController extends WP_REST_Controller
{
    private OAuth2ServerService $oauthService;

    public function __construct(OAuth2ServerService $oauthService)
    {
        $this->namespace = 'oauth/v2';
        $this->rest_base = 'token';
        $this->oauthService = $oauthService;
    }

    public function register_routes(): void
    {
        register_rest_route($this->namespace, '/' . $this->rest_base, [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'handleTokenRequest'],
                'permission_callback' => '__return_true', // Public token exchange endpoint
            ],
        ]);

        register_rest_route($this->namespace, '/revoke', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'handleRevokeRequest'],
                'permission_callback' => '__return_true',
            ],
        ]);
    }

    public function handleTokenRequest(WP_REST_Request $request): WP_REST_Response|WP_Error
    {
        $grantType = sanitize_text_field((string)$request->get_param('grant_type'));
        $clientId = sanitize_text_field((string)$request->get_param('client_id'));

        if (empty($clientId)) {
            return new WP_Error('invalid_request', 'Missing client_id parameter.', ['status' => 400]);
        }

        if ($grantType === 'authorization_code') {
            $code = sanitize_text_field((string)$request->get_param('code'));
            $redirectUri = esc_url_raw((string)$request->get_param('redirect_uri'));
            $codeVerifier = sanitize_text_field((string)$request->get_param('code_verifier'));

            $result = $this->oauthService->exchangeCodeForTokens($clientId, $code, $redirectUri, $codeVerifier);
            if (is_wp_error($result)) {
                return $result;
            }

            return new WP_REST_Response($result, 200);
        }

        if ($grantType === 'refresh_token') {
            $refreshToken = sanitize_text_field((string)$request->get_param('refresh_token'));

            $result = $this->oauthService->refreshAccessToken($clientId, $refreshToken);
            if (is_wp_error($result)) {
                return $result;
            }

            return new WP_REST_Response($result, 200);
        }

        return new WP_Error('unsupported_grant_type', 'Unsupported grant_type parameter.', ['status' => 400]);
    }

    public function handleRevokeRequest(WP_REST_Request $request): WP_REST_Response
    {
        global $wpdb;
        $token = sanitize_text_field((string)$request->get_param('token'));

        if (!empty($token)) {
            $tokenHash = hash('sha256', $token);
            $wpdb->update($wpdb->prefix . 'oauth_access_tokens', ['is_revoked' => 1], ['token_hash' => $tokenHash]);
            $wpdb->update($wpdb->prefix . 'oauth_refresh_tokens', ['is_revoked' => 1], ['refresh_token_hash' => $tokenHash]);
        }

        return new WP_REST_Response(['revoked' => true], 200);
    }
}

7. In-Memory Token Caching with Redis & Fast Invalidation

Querying MySQL on every single API request introduces unnecessary latency and database load. We implement an in-memory Redis caching layer that stores hashed token payloads for the duration of their lifespan while enabling instant O(1) invalidation when a token is revoked.

<?php
declare(strict_types=1);

namespace WPStack\OAuth2\Cache;

use Redis;
use RedisException;

final class RedisTokenCacheManager
{
    private ?Redis $redis = null;
    private const CACHE_PREFIX = 'wpstack_oauth_token:';

    public function __construct()
    {
        if (class_exists('Redis')) {
            try {
                $this->redis = new Redis();
                $this->redis->connect(
                    (string)defined('WP_REDIS_HOST') ? WP_REDIS_HOST : '127.0.0.1',
                    (int)defined('WP_REDIS_PORT') ? WP_REDIS_PORT : 6379,
                    1.5
                );
            } catch (RedisException) {
                $this->redis = null; // Fallback gracefully to direct MySQL lookups
            }
        }
    }

    public function getCachedToken(string $tokenHash): ?array
    {
        if (!$this->redis) {
            return null;
        }

        try {
            $data = $this->redis->get(self::CACHE_PREFIX . $tokenHash);
            return $data ? json_decode((string)$data, true) : null;
        } catch (RedisException) {
            return null;
        }
    }

    public function setCachedToken(string $tokenHash, array $tokenData, int $ttl): void
    {
        if (!$this->redis || $ttl <= 0) {
            return;
        }

        try {
            $this->redis->setex(self::CACHE_PREFIX . $tokenHash, $ttl, (string)wp_json_encode($tokenData));
        } catch (RedisException) {
            // Log silent cache write failure
        }
    }

    public function invalidateToken(string $tokenHash): void
    {
        if (!$this->redis) {
            return;
        }

        try {
            $this->redis->del(self::CACHE_PREFIX . $tokenHash);
        } catch (RedisException) {
            // Invalidation error fallback
        }
    }
}

8. User Consent UI: Authorization Screen Controller

When a third-party application requests access, the user is presented with a clear consent screen showing the application name, verified developer information, requested permission scopes, and an explicit Grant/Deny dialog.

Requested Permissions:

✓


Authorize Access



9. WP-CLI Management Interface for OAuth2 Clients & Tokens

DevOps and automated deployment pipelines require command-line interfaces to register new client applications, generate credentials, and purge expired tokens.

<?php
declare(strict_types=1);

namespace WPStack\OAuth2\Cli;

use WP_CLI;
use WP_CLI_Command;

if (!defined('WP_CLI') || !WP_CLI) {
    return;
}

final class OAuthCliCommand extends WP_CLI_Command
{
    /**
     * Creates a new OAuth2 client application.
     *
     * ## OPTIONS
     *
     * 
     * : Friendly application name.
     *
     * --redirect-uri=
     * : Allowed callback URI.
     *
     * [--scopes=]
     * : Space-delimited allowed scopes.
     * ---
     * default: orders:read products:write
     * ---
     *
     * ## EXAMPLES
     *
     *     wp wpstack oauth create-client "Mobile App" --redirect-uri="https://app.example.com/oauth/callback"
     */
    public function create_client(array $args, array $assocArgs): void
    {
        global $wpdb;
        $name = $args[0];
        $redirectUri = esc_url_raw($assocArgs['redirect-uri'] ?? '');
        $scopes = sanitize_text_field($assocArgs['scopes'] ?? 'orders:read');

        if (empty($redirectUri)) {
            WP_CLI::error('A valid --redirect-uri is required.');
        }

        $clientId = 'client_' . bin2hex(random_bytes(16));
        $table = $wpdb->prefix . 'oauth_clients';

        $wpdb->insert($table, [
            'client_id'       => $clientId,
            'user_id'         => 1, // Super admin owner
            'name'            => $name,
            'redirect_uris'   => $redirectUri,
            'allowed_scopes'  => $scopes,
            'is_confidential' => 0,
            'created_at'      => current_time('mysql'),
        ]);

        WP_CLI::success('OAuth2 Client Created Successfully!');
        \WP_CLI\Utils\format_items('table', [[
            'Client ID'    => $clientId,
            'App Name'     => $name,
            'Redirect URI' => $redirectUri,
            'Scopes'       => $scopes,
        ]], ['Client ID', 'App Name', 'Redirect URI', 'Scopes']);
    }

    /**
     * Cleans up expired authorization codes and access tokens.
     *
     * ## EXAMPLES
     *
     *     wp wpstack oauth prune
     */
    public function prune(array $args, array $assocArgs): void
    {
        global $wpdb;
        $now = time();

        $deletedCodes = $wpdb->query(
            $wpdb->prepare("DELETE FROM {$wpdb->prefix}oauth_auth_codes WHERE expires_at < %d OR is_used = 1", $now)
        );

        $deletedTokens = $wpdb->query(
            $wpdb->prepare("DELETE FROM {$wpdb->prefix}oauth_access_tokens WHERE expires_at < %d OR is_revoked = 1", $now)
        );

        WP_CLI::success(sprintf('Pruned %d expired auth codes and %d expired tokens.', $deletedCodes, $deletedTokens));
    }
}

WP_CLI::add_command('wpstack oauth', OAuthCliCommand::class);

8. Automated PHPUnit Integration Test Suite

To ensure security resilience, we construct automated PHPUnit test fixtures verifying the entire PKCE challenge verification matrix, expired token rejection, and route scope authorization.

<?php
declare(strict_types=1);

namespace WPStack\OAuth2\Tests;

use WP_UnitTestCase;
use WPStack\OAuth2\Services\OAuth2ServerService;
use WPStack\OAuth2\Middleware\OAuth2AuthMiddleware;

final class OAuth2SecurityTest extends WP_UnitTestCase
{
    private OAuth2ServerService $service;

    public function set_up(): void
    {
        parent::set_up();
        global $wpdb;
        $this->service = new OAuth2ServerService($wpdb);
    }

    public function testPkceS256AuthorizationCodeExchange(): void
    {
        $userId = $this->factory->user->create(['role' => 'administrator']);
        $clientId = 'test_client_123';
        $redirectUri = 'https://app.test/callback';

        // 1. Generate PKCE Verifier and S256 Challenge
        $codeVerifier = bin2hex(random_bytes(32));
        $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');

        // 2. Issue Auth Code
        $rawCode = $this->service->issueAuthorizationCode(
            $clientId,
            $userId,
            $redirectUri,
            $codeChallenge,
            'S256',
            ['orders:read', 'products:write']
        );

        $this->assertNotEmpty($rawCode);

        // 3. Exchange Code with Correct Verifier
        $tokenResult = $this->service->exchangeCodeForTokens($clientId, $rawCode, $redirectUri, $codeVerifier);

        $this->assertIsArray($tokenResult);
        $this->assertArrayHasKey('access_token', $tokenResult);
        $this->assertArrayHasKey('refresh_token', $tokenResult);
        $this->assertSame(3600, $tokenResult['expires_in']);

        // 4. Replay Attack Test: Attempting to reuse the same code must fail
        $replayResult = $this->service->exchangeCodeForTokens($clientId, $rawCode, $redirectUri, $codeVerifier);
        $this->assertWPError($replayResult, 'Reused authorization codes must be rejected.');
    }

    public function testTamperedVerifierIsRejected(): void
    {
        $userId = $this->factory->user->create(['role' => 'editor']);
        $clientId = 'test_client_456';
        $redirectUri = 'https://app.test/callback';

        $codeVerifier = 'legitimate-random-verifier-string-1234567890';
        $codeChallenge = rtrim(strtr(base64_encode(hash('sha256', $codeVerifier, true)), '+/', '-_'), '=');

        $rawCode = $this->service->issueAuthorizationCode(
            $clientId,
            $userId,
            $redirectUri,
            $codeChallenge,
            'S256',
            ['orders:read']
        );

        // Attempt exchange with forged verifier
        $result = $this->service->exchangeCodeForTokens($clientId, $rawCode, $redirectUri, 'tampered-verifier-string');
        $this->assertWPError($result);
        $this->assertSame('invalid_grant', $result->get_error_code());
    }
}

9. High-Concurrency Stress Testing with k6

To guarantee that our authentication middleware introduces negligible overhead during massive traffic spikes, we execute a k6 load test simulating 100 concurrent API consumers executing authenticated REST requests.

import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  stages: [
    { duration: '30s', target: 25 },
    { duration: '1m', target: 100 }, // Sustained load at 100 concurrent requests
    { duration: '30s', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<80'], // 95% of auth checks under 80ms
    http_req_failed: ['rate<0.001'], // Failure rate under 0.1%
  },
};

export default function () {
  const params = {
    headers: {
      'Authorization': 'Bearer wpk_test_access_token_mock_hash',
      'Content-Type': 'application/json',
    },
  };

  const res = http.get('https://wpstack.online/wp-json/wp/v2/posts?per_page=10', params);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'authenticated user acknowledged': (r) => r.headers['X-Authenticated-By'] !== undefined,
    'latency under 60ms': (r) => r.timings.duration < 60,
  });

  sleep(0.5);
}

10. Operational Incident Runbook: Troubleshooting OAuth2 Failures

Production Runbook: OAuth2 Server Incidents

  1. HTTP Authorization Header Stripped by Nginx / Apache:
    • Symptom: REST API returns 401 Unauthorized with "Bearer token missing", even when the client passes the Authorization: Bearer ... header.
    • Root Cause: FastCGI / PHP-FPM strips the Authorization header by default unless explicitly forwarded in web server configuration.
    • Remedy: In Nginx, add fastcgi_param HTTP_AUTHORIZATION $http_authorization; to the PHP location block. In Apache .htaccess, add SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1.
  2. Clock Drift Causing Premature Token Expiration:
    • Symptom: Clients report valid access tokens immediately failing with expired_token errors.
    • Root Cause: Application server clock drifted out of sync with the client device clock by > 60 minutes.
    • Remedy: Resynchronize the server NTP daemon via sudo systemctl restart systemd-timesyncd.
  3. Replay Attack Alert on Authorization Codes:
    • Symptom: High frequency of invalid_grant errors on /oauth/v2/token.
    • Root Cause: A client application is mistakenly making duplicate concurrent POST requests with the same authorization code.
    • Remedy: Verify client retry logic and ensure the client awaits token response before navigating.

Frequently Asked Questions (FAQ)

1. Why is OAuth 2.0 with PKCE superior to WordPress Application Passwords?

Application Passwords are static, never expire, and grant complete user administrative permissions without scoping boundaries. OAuth 2.0 with PKCE issues time-limited tokens (60 minutes), enforces granular per-route least privilege scopes (e.g. `orders:read`), and prevents credential exposure on mobile devices and single-page apps.

2. What is PKCE (Proof Key for Code Exchange) and why is it mandatory?

PKCE (RFC 7636) prevents authorization code interception attacks on public clients where a client secret cannot be securely stored. By creating a cryptographic `code_verifier` and hashing it into a `code_challenge`, only the application that initiated the authorization flow can exchange the code for tokens.

3. How does the REST API middleware authenticate incoming Bearer tokens?

The middleware hooks into the WordPress `rest_authentication_errors` filter, parses the `Authorization: Bearer ` header, hashes the token with SHA-256 to look up its record, verifies expiration and revocation status, and sets the authenticated user context using `wp_set_current_user()`.

4. How does Refresh Token Rotation work in this architecture?

Every time a client submits a refresh token to obtain a new access token, the used refresh token is immediately marked as revoked, and a brand new refresh token is issued alongside the new access token. If an attacker attempts to reuse an old refresh token, the server immediately revokes all tokens for that session.

5. How do I prevent Nginx or Apache from stripping Authorization headers?

In Nginx, include `fastcgi_param HTTP_AUTHORIZATION $http_authorization;` inside your `location ~ \.php$` block. In Apache `.htaccess`, add `RewriteEngine On` followed by `RewriteCond %{HTTP:Authorization} ^(.*)` and `RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]`.

6. Are access tokens stored in plaintext in the database?

No. Following standard cryptographic security practices, only SHA-256 hashes of access tokens and refresh tokens are stored in the database. Even in the event of a database compromise, attackers cannot reconstruct the valid plaintext bearer tokens.

7. Can OAuth2 scopes restrict WooCommerce REST API endpoints?

Yes. The `enforceRouteScopes` middleware inspects the REST route and HTTP method before dispatch. For example, requests to `/wc/v3/orders` require the `orders:read` or `orders:write` scope. If the token lacks the requisite scope, a 403 Forbidden response is returned before WooCommerce controller logic executes.

8. Can OAuth2 token verification be cached in Redis?

Yes. Active token metadata can be cached in Redis with a TTL matching the token expiration timestamp. When a token is revoked, the corresponding Redis cache key is invalidated instantly, eliminating database queries on high-traffic REST endpoints.

Primary references

Post a Comment