Newer
Older
ojs-wordpress-plugin / OjsWpSsoSubscriptionPlugin.php
<?php

/**
 * @file OjsWpSsoSubscriptionPlugin.php
 *
 * Copyright (c) 2024-2026 GuIT - Gruppo Utilizzatori Italiani di TeX
 * Distributed under the GNU GPL v3.
 *
 * @class OjsWpSsoSubscriptionPlugin
 * @brief Syncs OJS individual subscriptions with an OIDC claim from any
 *        OpenID Connect provider (e.g. WordPress with WP OJS SSO Bridge).
 *        Decrypts pkp/openid id_token when stored encrypted; userinfo with
 *        access token when present in session. Hides subscription-only issues
 *        from non-subscribers.
 */

namespace APP\plugins\generic\ojsWpSsoSubscription;

use APP\core\Application;
use PKP\db\DAORegistry;
use PKP\plugins\GenericPlugin;
use PKP\plugins\Hook;
use PKP\plugins\PluginRegistry;

class OjsWpSsoSubscriptionPlugin extends GenericPlugin
{
    public function register($category, $path, $mainContextId = null)
    {
        $success = parent::register($category, $path, $mainContextId);

        if ($success && $this->getEnabled()) {
            // Sync + periodic revalidation of subscription status (reads JWT
            // id_token from the OpenID plugin session, falls back to userinfo).
            // NOTE: the third-party OpenID plugin does NOT emit an
            // "authenticated" hook, so we rely on LoadHandler and sync lazily.
            Hook::add('LoadHandler', [$this, 'validateSubscriptionOnRequest']);

            // Hide subscription issues from archive
            Hook::add('IssueGridHandler::fetchGrid', [$this, 'filterIssueArchive']);

            // After core subscription check: sync OIDC claim into DB and re-evaluate
            // (IssueHandler / ArticleHandler gate PDFs on subscribedUser).
            Hook::add('IssueAction::subscribedUser', [$this, 'hookIssueActionSubscribedUser'], Hook::SEQUENCE_LAST);

            // Homepage only: OJS 3.4 has no IssueHandler::view hook; do not swap
            // frontend/pages/issue.tpl — logged-in users without a synced OJS row would
            // see only "subscription required" while the core would show TOC + locked PDFs.
            Hook::add('TemplateManager::display', [$this, 'filterTemplateManagerDisplay']);

            // AboutHandler::subscriptions redirects to index when payments are not
            // enabled+configured. SSO-only sites hit that, so "PDF denied → about/subscriptions
            // → index" looks like a double bounce. Aborting that redirect lets the
            // subscriptions page render (contact info, types, etc.).
            Hook::add('Request::redirect', [$this, 'hookRequestRedirect'], Hook::SEQUENCE_NORMAL);
        }

        return $success;
    }

    public function getDisplayName()
    {
        return __('plugins.generic.ojsWpSsoSubscription.displayName');
    }

    public function getDescription()
    {
        return __('plugins.generic.ojsWpSsoSubscription.description');
    }

    // -------------------------------------------------------------------------
    // Settings
    // -------------------------------------------------------------------------

    public function getActions($request, $actionArgs)
    {
        $actions = parent::getActions($request, $actionArgs);
        if (!$this->getEnabled()) {
            return $actions;
        }

        $router = $request->getRouter();

        array_unshift(
            $actions,
            new \PKP\linkAction\LinkAction(
                'settings',
                new \PKP\linkAction\request\AjaxModal(
                    $router->url($request, null, null, 'manage', null, [
                        'verb'   => 'settings',
                        'plugin' => $this->getName(),
                        'category' => 'generic',
                    ]),
                    $this->getDisplayName()
                ),
                __('manager.plugins.settings'),
                null
            )
        );

        return $actions;
    }

    public function manage($args, $request)
    {
        switch ($request->getUserVar('verb')) {
            case 'settings':
                $context = $request->getContext();
                $contextId = $context ? $context->getId() : \PKP\core\PKPApplication::CONTEXT_SITE;

                $formFile = __DIR__ . '/OjsWpSsoSubscriptionSettingsForm.php';
                if (!file_exists($formFile)) {
                    $formFile = __DIR__ . '/OjsWpSsoSubscriptionSettingsForm.inc.php';
                }
                require_once $formFile;

                $form = new OjsWpSsoSubscriptionSettingsForm($this, $contextId);
                if ($request->getUserVar('save')) {
                    $form->readInputData();
                    if ($form->validate()) {
                        $form->execute();
                        return new \PKP\core\JSONMessage(true);
                    }
                } else {
                    $form->initData();
                }
                return new \PKP\core\JSONMessage(true, $form->fetch($request));
        }
        return parent::manage($args, $request);
    }

    // -------------------------------------------------------------------------
    // Subscription sync & periodic revalidation
    // -------------------------------------------------------------------------

    /**
     * Decode an OIDC JWT id_token (signature NOT re-verified: the OpenID plugin
     * already did that before storing it).
     *
     * @return array|null  Associative array of claims, or null on failure.
     */
    private function decodeIdTokenClaims($idToken)
    {
        if (empty($idToken) || !is_string($idToken) || substr_count($idToken, '.') < 2) {
            return null;
        }
        $parts = explode('.', $idToken);
        $payload = strtr($parts[1], '-_', '+/');
        $pad = strlen($payload) % 4;
        if ($pad) {
            $payload .= str_repeat('=', 4 - $pad);
        }
        $json = base64_decode($payload, true);
        if ($json === false) {
            return null;
        }
        $claims = json_decode($json, true);
        return is_array($claims) ? $claims : null;
    }

    /**
     * Permissive truth check for the subscription claim:
     * true, 1, "1", "true", "yes", "on" are all truthy.
     */
    private function claimIsTruthy($value)
    {
        if ($value === true || $value === 1) {
            return true;
        }
        if (is_string($value)) {
            return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
        }
        return false;
    }

    /**
     * Raw id_token string as stored in session (may be encrypted by OpenID plugin).
     */
    private function getSessionIdTokenRaw()
    {
        try {
            $session = Application::get()->getRequest()->getSession();
            if (!$session) {
                return null;
            }
            $keyName = 'id_token';
            if (class_exists('\\APP\\plugins\\generic\\openid\\OpenIDPlugin')
                && defined('\\APP\\plugins\\generic\\openid\\OpenIDPlugin::ID_TOKEN_NAME')) {
                $keyName = \APP\plugins\generic\openid\OpenIDPlugin::ID_TOKEN_NAME;
            }
            if (method_exists($session, 'getSessionVar')) {
                return $session->getSessionVar($keyName);
            }
            if (method_exists($session, 'get')) {
                return $session->get($keyName);
            }
        } catch (\Throwable $e) {
            error_log('[ojsWpSsoSubscription] session read failed: ' . $e->getMessage());
        }
        return null;
    }

    /**
     * JWT id_token for claim parsing: pkp/openid stores the id_token encrypted
     * via OpenIDPlugin::encryptOrDecrypt; without decrypt, base64 decode fails.
     */
    private function getDecryptedIdTokenString()
    {
        $raw = $this->getSessionIdTokenRaw();
        if ($raw === null || $raw === '') {
            return null;
        }
        if (!class_exists('\\APP\\plugins\\generic\\openid\\OpenIDPlugin')) {
            return is_string($raw) ? $raw : null;
        }
        $request = Application::get()->getRequest();
        $context = $request->getContext();
        $contextId = $context ? (int) $context->getId() : \PKP\core\PKPApplication::CONTEXT_SITE;

        $openid = PluginRegistry::getPlugin('generic', 'openid');
        if (!$openid) {
            $loadCtx = $context ? (int) $context->getId() : \PKP\core\PKPApplication::CONTEXT_SITE;
            $openid = PluginRegistry::loadPlugin('generic', 'openid', $loadCtx);
        }
        if (!$openid) {
            $openid = PluginRegistry::loadPlugin('generic', 'openid', 1);
        }
        if (!$openid instanceof \APP\plugins\generic\openid\OpenIDPlugin) {
            return is_string($raw) ? $raw : null;
        }
        $dec = \APP\plugins\generic\openid\OpenIDPlugin::encryptOrDecrypt($openid, $contextId, $raw, false);
        if (is_string($dec) && substr_count($dec, '.') >= 2) {
            return $dec;
        }

        return is_string($raw) ? $raw : null;
    }

    /**
     * Optional access_token in session. Stock pkp/openid does not persist it; if you
     * patch OpenID to $session->setSessionVar('access_token', $token['access_token']), userinfo
     * will be called with the correct Bearer (RFC 6750).
     */
    private function getSessionAccessTokenRaw()
    {
        try {
            $session = Application::get()->getRequest()->getSession();
            if (!$session) {
                return null;
            }
            if (method_exists($session, 'getSessionVar')) {
                $v = $session->getSessionVar('access_token');
            } elseif (method_exists($session, 'get')) {
                $v = $session->get('access_token');
            } else {
                $v = null;
            }
            return (is_string($v) && $v !== '') ? $v : null;
        } catch (\Throwable $e) {
            error_log('[ojsWpSsoSubscription] access_token session read failed: ' . $e->getMessage());
        }
        return null;
    }

    /**
     * UserInfo (OIDC): try Bearer = access_token first, then id_token (some IdPs accept it).
     */
    private function fetchUserinfoClaims($userinfoUrl, ?string $accessToken, ?string $idToken)
    {
        if (empty($userinfoUrl)) {
            return null;
        }
        foreach (array_unique(array_filter([$accessToken, $idToken])) as $bearer) {
            $data = $this->fetchUserinfoClaimsWithBearer($userinfoUrl, $bearer);
            if (is_array($data)) {
                return $data;
            }
        }
        return null;
    }

    private function fetchUserinfoClaimsWithBearer($userinfoUrl, $bearer)
    {
        $ch = curl_init($userinfoUrl);
        $headers = ['Accept: application/json'];
        if ($bearer !== null && $bearer !== '') {
            $headers[] = 'Authorization: Bearer ' . $bearer;
        }
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 5,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER     => $headers,
        ]);
        $response = curl_exec($ch);
        $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        if ($httpCode !== 200 || !$response) {
            return null;
        }
        $data = json_decode($response, true);
        return is_array($data) ? $data : null;
    }

    /**
     * Apply OIDC subscription claim to the DB for this user/journal.
     *
     * @param bool $respectThrottle When false, always attempt claim read + DB update
     *                              (used when gating PDF access via IssueAction::subscribedUser).
     */
    private function syncSubscriptionFromOidcClaims(\PKP\user\User $user, int $contextId, bool $respectThrottle): void
    {
        $typeId = (int) ($this->getSetting($contextId, 'subscriptionTypeId') ?: 0);
        if (!$typeId) {
            return;
        }

        /** @var \IndividualSubscriptionDAO $subscriptionDao */
        $subscriptionDao = DAORegistry::getDAO('IndividualSubscriptionDAO');
        $existing = $subscriptionDao->getByUserIdForJournal($user->getId(), $contextId);

        if ($respectThrottle) {
            $interval = (int) ($this->getSetting($contextId, 'revalidateInterval') ?: 300);
            $lastCheckKey = 'lastCheck_' . $user->getId();
            $lastCheck = (int) ($this->getSetting($contextId, $lastCheckKey) ?: 0);
            $hasActive = $subscriptionDao->isValidIndividualSubscription(
                (int) $user->getId(),
                (int) $contextId
            );
            if ($hasActive && $interval > 0 && $lastCheck && (time() - $lastCheck) < $interval) {
                return;
            }
        }

        $idToken = $this->getDecryptedIdTokenString();
        $claims = $this->decodeIdTokenClaims($idToken);
        if (!is_array($claims)) {
            $claims = [];
        }

        $claimName = $this->getSetting($contextId, 'claimName') ?: 'subscription_active';
        $accessToken = $this->getSessionAccessTokenRaw();
        if (!array_key_exists($claimName, $claims)) {
            $userinfoUrl = $this->getSetting($contextId, 'userinfoUrl');
            $ui = $this->fetchUserinfoClaims($userinfoUrl, $accessToken, $idToken);
            if (is_array($ui)) {
                $claims = array_merge($claims, $ui);
            }
        }

        if (!array_key_exists($claimName, $claims)) {
            return;
        }

        $lastCheckKey = 'lastCheck_' . $user->getId();
        $this->updateSetting($contextId, $lastCheckKey, time());

        $duration = (int) ($this->getSetting($contextId, 'subscriptionDuration') ?: 12);
        $isActive = $this->claimIsTruthy($claims[$claimName]);

        if ($isActive) {
            if (!$existing) {
                $sub = $subscriptionDao->newDataObject();
                $sub->setJournalId($contextId);
                $sub->setUserId($user->getId());
                $sub->setTypeId($typeId);
                $sub->setStatus(SUBSCRIPTION_STATUS_ACTIVE);
                $sub->setDateStart(date('Y-m-d'));
                $sub->setDateEnd(date('Y-m-d', strtotime("+{$duration} months")));
                $subscriptionDao->insertObject($sub);
                error_log('[ojsWpSsoSubscription] created subscription for user_id=' . $user->getId());
            } else {
                // Refresh dates whenever the OIDC claim is still true. Otherwise a row
                // can stay STATUS_ACTIVE with date_end in the past and
                // isValidIndividualSubscription() keeps denying PDF access.
                $wasInactive = ((int) $existing->getStatus() !== SUBSCRIPTION_STATUS_ACTIVE);
                $existing->setStatus(SUBSCRIPTION_STATUS_ACTIVE);
                $existing->setDateEnd(date('Y-m-d', strtotime("+{$duration} months")));
                $subscriptionDao->updateObject($existing);
                error_log($wasInactive
                    ? '[ojsWpSsoSubscription] reactivated subscription for user_id=' . $user->getId()
                    : '[ojsWpSsoSubscription] renewed subscription end date for user_id=' . $user->getId());
            }
        } elseif ($existing && (int) $existing->getStatus() === SUBSCRIPTION_STATUS_ACTIVE) {
            $existing->setStatus(SUBSCRIPTION_STATUS_NEEDS_APPROVAL);
            $subscriptionDao->updateObject($existing);
            error_log('[ojsWpSsoSubscription] deactivated subscription for user_id=' . $user->getId());
        }
    }

    /**
     * Main hook: every request, possibly sync the user's subscription based
     * on the subscription claim. Rate-limited via revalidateInterval, but
     * bypassed if the user has no active subscription yet (first-time sync).
     */
    public function validateSubscriptionOnRequest($hookName, $args)
    {
        $request = Application::get()->getRequest();
        $user    = $request->getUser();
        if (!$user) {
            return false;
        }

        $context   = $request->getContext();
        $contextId = $context ? $context->getId() : 0;
        if (!$contextId) {
            return false;
        }

        if (!(int) ($this->getSetting($contextId, 'subscriptionTypeId') ?: 0)) {
            return false;
        }

        // LoadHandler passes [&$page, &$op, &$sourceFile, &$handler] as a single array
        // (see PKPPageRouter). Always re-sync before PDF/issue gates on these ops.
        $page = is_array($args) && isset($args[0]) ? $args[0] : null;
        $op   = is_array($args) && isset($args[1]) ? $args[1] : null;
        $subscriptionGate = in_array($page, ['issue', 'article'], true)
            && in_array($op, ['view', 'download'], true);

        $this->syncSubscriptionFromOidcClaims($user, $contextId, !$subscriptionGate);

        return false;
    }

    /**
     * After core computes subscription access, sync from OIDC without throttle
     * and grant access if the DB row is now valid (fixes PDF gate on first hit).
     */
    public function hookIssueActionSubscribedUser($hookName, $args)
    {
        if (!isset($args[0], $args[1]) || !$args[0] instanceof \PKP\user\User || !$args[1]) {
            return false;
        }

        if (!empty($args[4])) {
            return false;
        }

        $user      = $args[0];
        $journal   = $args[1];
        $contextId = (int) $journal->getId();
        if (!$contextId || !(int) ($this->getSetting($contextId, 'subscriptionTypeId') ?: 0)) {
            return false;
        }

        $this->syncSubscriptionFromOidcClaims($user, $contextId, false);

        /** @var \IndividualSubscriptionDAO $subscriptionDao */
        $subscriptionDao = DAORegistry::getDAO('IndividualSubscriptionDAO');
        if ($subscriptionDao->isValidIndividualSubscription((int) $user->getId(), $contextId)) {
            $args[4] = true;
        }

        return false;
    }

    /**
     * Block the core redirect from about/subscriptions to index when PayPal/Stripe
     * is off (SSO-only). Aborting the redirect returns control to AboutHandler so
     * subscriptions.tpl can be displayed.
     */
    public function hookRequestRedirect($hookName, $args)
    {
        if (!isset($args[0]) || $args[0] === '') {
            return Hook::CONTINUE;
        }

        $request = Application::get()->getRequest();
        if ($request->getRequestedPage() !== 'about' || $request->getRequestedOp() !== 'subscriptions') {
            return Hook::CONTINUE;
        }

        $context = $request->getContext();
        if (!$context || !(int) ($this->getSetting($context->getId(), 'subscriptionTypeId') ?: 0)) {
            return Hook::CONTINUE;
        }

        return Hook::ABORT;
    }

    // -------------------------------------------------------------------------
    // Hide subscription issues
    // -------------------------------------------------------------------------

    /**
     * Must match OJS core: IssueAction::subscribedUser() uses
     * IndividualSubscriptionDAO::isValidIndividualSubscription(), which also
     * requires subscription_types.format to be Online (1) or Print+Online (17).
     * A row with status ACTIVE but format = Print-only (16) shows the issue
     * (if we only checked status) but hides PDF galleys in the UI.
     */
    private function userHasSubscription($userId, $contextId)
    {
        /** @var \IndividualSubscriptionDAO $subscriptionDao */
        $subscriptionDao = DAORegistry::getDAO('IndividualSubscriptionDAO');

        return $subscriptionDao->isValidIndividualSubscription(
            (int) $userId,
            (int) $contextId
        );
    }

    public function filterIssueArchive($hookName, $args)
    {
        if (!isset($args[0]) || !is_array($args[0])) {
            return false;
        }

        $request   = Application::get()->getRequest();
        $user      = $request->getUser();
        $context   = $request->getContext();
        $contextId = $context ? $context->getId() : 0;

        if ($user && $this->userHasSubscription($user->getId(), $contextId)) {
            return false;
        }

        $args[0] = array_filter($args[0], function ($issue) {
            return $issue->getAccessStatus() != ISSUE_ACCESS_SUBSCRIPTION;
        });

        return false;
    }

    /**
     * Homepage: hide current issue if subscription-only and user has no access.
     * Issue pages always use core OJS (TOC + locked or open galleys); blocking the
     * whole issue for logged-in users without a synced DB row was confusing and
     * differed from the old (never-fired) IssueHandler::view hook.
     */
    public function filterTemplateManagerDisplay($hookName, $args)
    {
        if (!isset($args[1])) {
            return false;
        }

        $templateFile = $args[1];
        $templateMgr = $args[0];

        if (strpos($templateFile, 'frontend/pages/index') === false) {
            return false;
        }

        $request = Application::get()->getRequest();
        $user = $request->getUser();
        $context = $request->getContext();
        $contextId = $context ? $context->getId() : 0;

        if ($user && $this->userHasSubscription($user->getId(), $contextId)) {
            return false;
        }

        $issue = $templateMgr->getTemplateVars('issue');
        if ($issue && $issue->getAccessStatus() == ISSUE_ACCESS_SUBSCRIPTION) {
            $templateMgr->assign('issue', null);
        }

        return false;
    }
}