<?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;
}
}