Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Activity/AuthorizeClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ public function extractParams($eventData)
'version' => 'v1',
'client' => $client,
'userLogin' => $activityData['userLogin'] ?? null,
'scopes' => array_values((array) ($activityData['scopes'] ?? [])),
// the scopes that were granted, empty when the request was denied
'scopes' => $decision === 'allowed' ? array_values((array) ($activityData['scopes'] ?? [])) : [],
'requestedScopes' => array_values((array) ($activityData['requestedScopes'] ?? [])),
'decision' => $decision,
];
}
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
## Changelog

5.3.0 - 2026-08-03
- Authorize requests may now carry multiple scopes; the consent screen shows a radio group and the user grants exactly one scope (defaulting to the least privileged). Issued tokens still carry a single scope.
- Scopes offered on the consent screen are limited to those the authorizing user can actually grant. If the user's access level is too low for every requested scope, the error now says so and names the scopes, instead of reporting an invalid client scope mapping.
- Clients with an empty scope list now fall back to the globally allowed scopes during authorization (previously rejected), matching the token endpoint behaviour.
- The scope configured for a client is now applied as the maximum access level a user can grant it, as the setting has always been documented. A client configured with `matomo:admin` can therefore be granted `matomo:write` or `matomo:read` as well, which is what lets the user choose a scope on the consent screen. Clients can never be granted more than their configured scope. The client credentials grant is unchanged and still requires the exact configured scope, as no user is involved to choose a lower one.
- Added code to harden the consent decision handling on the authorize endpoint
- The `OAuth2.authorize.decision.end` event and the authorize activity keep reporting the granted scope in `scopes`, which is now empty when the request was denied, and add `requestedScopes` with everything the client asked for.

5.2.4 - 2026-07-27
- Added code to warn users if scope is downgraded

Expand Down
168 changes: 152 additions & 16 deletions Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Piwik\Request;
use Piwik\Url;
use Matomo\Dependencies\OAuth2\Psr\Http\Message\ResponseInterface;
use Matomo\Dependencies\OAuth2\League\OAuth2\Server\Entities\ClientEntityInterface;
use Matomo\Dependencies\OAuth2\League\OAuth2\Server\Exception\OAuthServerException;

class Controller extends ControllerAdmin
Expand Down Expand Up @@ -94,31 +95,78 @@ public function authorize()
return $scope->getIdentifier();
}, $authRequest->getScopes());

$client = $authRequest->getClient();
$clientScopes = [];
if ($client instanceof ClientEntity) {
$clientScopes = array_values($client->getAllowedScopes());
}
$scopes = array_values($scopes);
$requestedClientScopes = $this->getScopesAllowedForClient(array_values($scopes), $authRequest->getClient());

if (count($scopes) !== 1 || count($clientScopes) !== 1 || $clientScopes[0] !== $scopes[0]) {
if (empty($requestedClientScopes)) {
return $this->renderUnauthorized(Piwik::translate('OAuth2_InvalidClientScope'));
}

$this->checkDoesUserHasAccessAsPerScope($scopes[0]);
$selectableScopes = $this->getScopesUserCanGrant($requestedClientScopes);

if (empty($selectableScopes)) {
return $this->renderUnauthorized(Piwik::translate(
'OAuth2_NoAccessForRequestedScopes',
implode(', ', $requestedClientScopes)
));
}

if ($this->isPostRequest()) {
$decision = Request::fromRequest()->getStringParameter('decision', '');
$nonce = Request::fromRequest()->getStringParameter('nonce', '');
// the consent form is submitted as POST, and Request::fromRequest() lets a query string
// parameter of the same name win, so the decision must be read from the POST body only
$post = Request::fromPost();
$decision = $post->getStringParameter('decision', '');
$nonce = $post->getStringParameter('nonce', '');

if (!Nonce::verifyNonce('Oauth2.authorize', $nonce)) {
return $this->renderUnauthorized(Piwik::translate('OAuth2_InvalidAuthorizationRequest'));
}

if (!in_array($decision, ['allow', 'deny'], true)) {
return $this->renderUnauthorized(Piwik::translate('OAuth2_InvalidAuthorizationRequest'));
}

$selectedScope = $post->getStringParameter('selected_scope', '');

if (!in_array($selectedScope, $selectableScopes, true)) {
return $this->renderUnauthorized(Piwik::translate('OAuth2_InvalidScopeValue'));
}

$this->checkDoesUserHasAccessAsPerScope($selectedScope);

$authRequest->setScopes([$this->scopeRepository->getScopeEntityByIdentifier($selectedScope)]);

$isApproved = $decision === 'allow';
$authRequest->setAuthorizationApproved($isApproved);

/**
* Triggered after a user allowed or denied an OAuth 2.0 authorization request on the
* consent screen, before the authorization code is issued.
*
* Used by the plugin itself to record the decision in the activity log, and available
* to other plugins that need to audit or react to granted and denied access.
*
* @param array $activityData Details of the decision:
*
* - `version`: the payload version, currently `v1`.
* - `client`: the OAuth client, with `id` and `name`, plus
* `type` and `active` for clients of this plugin.
* - `userLogin`: the login of the user who decided.
* - `scopes`: the scopes that were granted. The user grants
* exactly one, so this holds a single scope, and it is
* empty when the request was denied.
* - `requestedScopes`: everything the client asked for. An
* authorize request may name several scopes even though
* only one of them can be granted.
* - `decision`: either `allowed` or `denied`.
*/
Piwik::postEvent('OAuth2.authorize.decision.end', [
$this->buildAuthorizationActivityData($authRequest->getClient(), $login, $scopes, $isApproved),
$this->buildAuthorizationActivityData(
$authRequest->getClient(),
$login,
array_values($scopes),
$selectedScope,
$isApproved
),
]);

try {
Expand Down Expand Up @@ -150,7 +198,7 @@ public function authorize()
'clientId' => $client->getIdentifier(),
'userLogin' => $login,
'userEmail' => $user['email'] ?? '',
'scopes' => $scopes,
'scopes' => $selectableScopes,
'scopeDescriptions' => $this->scopeRepository->describeScopes(),
'nonce' => Nonce::getNonce('Oauth2.authorize'),
'termsAndCondition' => $termsAndConditionUrl,
Expand Down Expand Up @@ -238,7 +286,7 @@ private function createServerRequest()

private function emitResponse(ResponseInterface $response)
{
http_response_code($response->getStatusCode());
$this->sendResponseCode($response->getStatusCode(), $response->getReasonPhrase());
foreach ($response->getHeaders() as $name => $values) {
foreach ($values as $value) {
Common::sendHeader($name . ': ' . $value);
Expand All @@ -255,12 +303,49 @@ private function isPostRequest(): bool

private function renderUnauthorized(string $message)
{
http_response_code(400);
$this->sendResponseCode(400);
return $message;
}

private function buildAuthorizationActivityData($client, string $login, array $scopes, bool $isApproved): array
private function sendResponseCode(int $statusCode, string $reasonPhrase = ''): void
{
try {
Common::sendResponseCode($statusCode);
return;
} catch (\Exception $e) {
// Common::sendResponseCode() only knows a fixed set of status codes and rejects the
// others, such as the 405 the token and metadata endpoints send for a wrong method.
}

Common::sendHeader(rtrim($this->getStatusHeaderPrefix() . ' ' . $statusCode . ' ' . $reasonPhrase));
}

/**
* Same prefix Common::sendResponseCode() uses, as FastCGI needs a Status header instead of a
* status line and the response should keep the protocol the request was made with.
*/
private function getStatusHeaderPrefix(): string
{
if (strpos(PHP_SAPI, '-fcgi') !== false) {
return 'Status:';
}

$protocol = $_SERVER['SERVER_PROTOCOL'] ?? '';

if (strlen($protocol) > 1 && strlen($protocol) < 15) {
return $protocol;
}

return 'HTTP/1.1';
}

private function buildAuthorizationActivityData(
$client,
string $login,
array $requestedScopes,
string $selectedScope,
bool $isApproved
): array {
$clientData = [
'id' => method_exists($client, 'getIdentifier') ? $client->getIdentifier() : null,
'name' => method_exists($client, 'getName') ? $client->getName() : null,
Expand All @@ -275,7 +360,10 @@ private function buildAuthorizationActivityData($client, string $login, array $s
'version' => 'v1',
'client' => $clientData,
'userLogin' => $login,
'scopes' => array_values($scopes),
// keeps reporting the effective permission, which is nothing at all when denied, so a
// listener reading only this field can never read more access than was granted
'scopes' => $isApproved ? [$selectedScope] : [],
'requestedScopes' => array_values($requestedScopes),
'decision' => $isApproved ? 'allowed' : 'denied',
];
}
Expand Down Expand Up @@ -305,6 +393,54 @@ private function checkDoesUserHasAccessAsPerScope(string $scope): void
case 'matomo:superuser':
Piwik::checkUserHasSuperUserAccess();
break;
default:
// never reached, the scope is validated against the selectable scopes beforehand
throw new \Exception(Piwik::translate('OAuth2_InvalidScopeValue'));
}
}

/**
* Returns the requested scopes the client is allowed to use, in ascending privilege order
* (read < write < admin < superuser).
*/
private function getScopesAllowedForClient(array $requestedScopes, ClientEntityInterface $client): array
{
$allowed = $this->scopeRepository->getAllowedScopeIds();

if ($client instanceof ClientEntity && !empty($client->getAllowedScopes())) {
// an empty client scope list means no client specific restriction, as in ScopeRepository::finalizeScopes()
$allowed = array_values(array_intersect(
$allowed,
ScopeRepository::expandScopes($client->getAllowedScopes())
));
}

return array_values(array_intersect($allowed, $requestedScopes));
}

/**
* Returns the scopes the current user has a high enough access level to grant.
*/
private function getScopesUserCanGrant(array $scopes): array
{
return array_values(array_filter($scopes, function (string $scope) {
return $this->canUserGrantScope($scope);
}));
}

private function canUserGrantScope(string $scope): bool
{
switch ($scope) {
case 'matomo:read':
return Piwik::isUserHasSomeViewAccess();
case 'matomo:write':
return Piwik::isUserHasSomeWriteAccess();
case 'matomo:admin':
return Piwik::isUserHasSomeAdminAccess();
case 'matomo:superuser':
return Piwik::hasUserSuperUserAccess();
default:
return false;
}
}
}
66 changes: 58 additions & 8 deletions OAuth2.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,51 @@ public static function getScopeDescriptions(): array
];
}

/**
* Maps each scope to the access role it confines a token to, and to its privilege level.
* The level is what orders the scopes, a higher level includes every lower one.
*
* @return array<string, array{role: string, level: int}>
*/
public static function getScopeAccessLevels(): array
{
return [
'matomo:read' => ['role' => 'view', 'level' => 1],
'matomo:write' => ['role' => 'write', 'level' => 2],
'matomo:admin' => ['role' => 'admin', 'level' => 3],
'matomo:superuser' => ['role' => 'superuser', 'level' => 4],
];
}

/**
* Returns the given scope together with every less privileged scope, in ascending order.
*
* The scope configured for a client is the maximum access level its tokens may get, as
* described by OAuth2_AdminScopeHelp, so a client allowed `matomo:admin` may also be
* granted `matomo:write` or `matomo:read`. Unknown scopes expand to nothing.
*
* @return string[]
*/
public static function expandScopeHierarchically(string $scope): array
{
$levels = self::getScopeAccessLevels();

if (!isset($levels[$scope])) {
return [];
}

$maximumLevel = $levels[$scope]['level'];
$expanded = [];

foreach ($levels as $identifier => $level) {
if ($level['level'] <= $maximumLevel) {
$expanded[] = $identifier;
}
}

return $expanded;
}

public function registerEvents()
{
return [
Expand Down Expand Up @@ -387,24 +432,29 @@ public static function getAuthorizationHeader(): ?string

private function scopeGrantsAtLeastWrite(?string $scope): bool
{
$scopeToLevel = ['matomo:read' => 1, 'matomo:write' => 2, 'matomo:admin' => 3, 'matomo:superuser' => 4];
$scopeLevels = self::getScopeAccessLevels();

// Unknown/empty scopes are treated as below write.
$level = $scopeToLevel[$scope] ?? 0;
$level = $scopeLevels[$scope]['level'] ?? 0;

return $level >= $scopeToLevel['matomo:write'];
return $level >= $scopeLevels['matomo:write']['level'];
}

private function modifyAccessBasedOnScope(?array $idSitesAccess, ?string $scope): array
{
$levels = ['view' => 1, 'write' => 2, 'admin' => 3, 'superuser' => 4];
$scopeToLevelMapping = ['matomo:read' => 'view', 'matomo:write' => 'write', 'matomo:admin' => 'admin', 'matomo:superuser' => 'superuser'];
if (empty($scopeToLevelMapping[$scope])) {
$scopeLevels = self::getScopeAccessLevels();

if (!isset($scopeLevels[$scope])) {
return [];
}

$target = $scopeToLevelMapping[$scope];
$targetLevel = $levels[$target];
$levels = [];
foreach ($scopeLevels as $scopeLevel) {
$levels[$scopeLevel['role']] = $scopeLevel['level'];
}

$target = $scopeLevels[$scope]['role'];
$targetLevel = $scopeLevels[$scope]['level'];

// Capture the subject's super user sites before the demotion loop empties them,
// so the capability reconciliation below can grant a super user every capability.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ https://matomo.example.com/index.php?module=OAuth2&action=authorize
The user will:

1. Log in to Matomo
2. Review requested permissions
2. Review requested permissions (if the request lists multiple space-separated scopes, pick exactly one to grant — the least privileged scope is preselected)
3. Click **Allow**

Matomo will redirect back:
Expand All @@ -241,7 +241,7 @@ https://example-app.com/oauth/callback?code=AUTHORIZATION_CODE&state=abc123

## Exchange Authorization Code for Tokens

**Note:** The scope should be same as client scope and only one scope is allowed at the moment.
**Note:** The authorize request may list multiple space-separated scopes (e.g. `scope=matomo:read matomo:write`). The user grants exactly one of them on the consent screen, and the issued tokens always carry that single scope. The scope configured for a client is the maximum a user can grant it, so a client configured with `matomo:admin` can also be granted `matomo:write` or `matomo:read`. Tokens obtained with the client credentials grant always use the exact configured scope.

### PKCE Token Request

Expand Down
Loading
Loading