Skip to content

Commit c97788c

Browse files
committed
feat: base#71 — Incremental consent in OAuthAccountController
When a Horde app needs a scope the user hasn't granted yet, it must trigger a re-authorization. For incremental consent it needs to accept additional scopes and merge them. See also #70 Model A
1 parent 962fc70 commit c97788c

4 files changed

Lines changed: 188 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright 2026 The Horde Project (http://www.horde.org/)
7+
*
8+
* See the enclosed file LICENSE for license information (LGPL). If you
9+
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
10+
*
11+
* @category Horde
12+
* @package Horde
13+
* @author Ralf Lang <ralf.lang@ralf-lang.de>
14+
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
15+
*/
16+
17+
namespace Horde\Horde\Factory;
18+
19+
use Horde\Core\Service\OAuthHttpClientService;
20+
use Horde\Core\Service\OAuthProviderConfigRepository;
21+
use Horde\Core\Service\OAuthTokenService;
22+
use Horde\Horde\Service\DefaultOAuthHttpClientService;
23+
use Horde_Injector;
24+
use Psr\Http\Client\ClientInterface;
25+
use Psr\Http\Message\RequestFactoryInterface;
26+
use Psr\Http\Message\StreamFactoryInterface;
27+
28+
class OAuthHttpClientServiceFactory
29+
{
30+
public function create(Horde_Injector $injector): OAuthHttpClientService
31+
{
32+
return new DefaultOAuthHttpClientService(
33+
tokenService: $injector->getInstance(OAuthTokenService::class),
34+
providerConfig: $injector->getInstance(OAuthProviderConfigRepository::class),
35+
httpClient: $injector->getInstance(ClientInterface::class),
36+
requestFactory: $injector->getInstance(RequestFactoryInterface::class),
37+
streamFactory: $injector->getInstance(StreamFactoryInterface::class),
38+
);
39+
}
40+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Copyright 2026 The Horde Project (http://www.horde.org/)
7+
*
8+
* See the enclosed file LICENSE for license information (LGPL). If you
9+
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
10+
*
11+
* @category Horde
12+
* @package Horde
13+
* @author Ralf Lang <ralf.lang@ralf-lang.de>
14+
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
15+
*/
16+
17+
namespace Horde\Horde\Service;
18+
19+
use Horde\Core\Service\Exception\OAuthInsufficientScopeException;
20+
use Horde\Core\Service\Exception\OAuthTokenNotFoundException;
21+
use Horde\Core\Service\OAuthHttpClientService;
22+
use Horde\Core\Service\OAuthProviderConfigRepository;
23+
use Horde\Core\Service\OAuthTokenService;
24+
use Horde\Core\Service\ScopeCheckResult;
25+
use Horde\Core\Service\WantedScopes;
26+
use Horde\OAuth\Client\AuthenticatedHttpClient;
27+
use Horde\OAuth\Client\ScopeSet;
28+
use Horde\OAuth\Client\TokenRefresher;
29+
use Psr\Http\Client\ClientInterface;
30+
use Psr\Http\Message\RequestFactoryInterface;
31+
use Psr\Http\Message\StreamFactoryInterface;
32+
33+
class DefaultOAuthHttpClientService implements OAuthHttpClientService
34+
{
35+
public function __construct(
36+
private readonly OAuthTokenService $tokenService,
37+
private readonly OAuthProviderConfigRepository $providerConfig,
38+
private readonly ClientInterface $httpClient,
39+
private readonly RequestFactoryInterface $requestFactory,
40+
private readonly StreamFactoryInterface $streamFactory,
41+
) {}
42+
43+
public function getClient(
44+
string $userId,
45+
string $providerId,
46+
?WantedScopes $wantedScopes = null,
47+
): AuthenticatedHttpClient {
48+
if ($wantedScopes !== null) {
49+
$result = $this->tokenService->checkScopes($userId, $providerId, $wantedScopes);
50+
51+
if ($result === ScopeCheckResult::NoToken) {
52+
throw new OAuthTokenNotFoundException(
53+
"No OAuth tokens for user '{$userId}' / provider '{$providerId}'"
54+
);
55+
}
56+
57+
if ($result === ScopeCheckResult::Insufficient) {
58+
$tokenSet = $this->tokenService->getTokenSet($userId, $providerId);
59+
$granted = ScopeSet::fromSpaceSeparated($tokenSet->scope);
60+
$missing = $wantedScopes->scopes->diff($granted)->toArray();
61+
62+
throw new OAuthInsufficientScopeException($tokenSet, $missing);
63+
}
64+
}
65+
66+
$tokenSet = $this->tokenService->getTokenSet($userId, $providerId);
67+
$config = $this->providerConfig->get($providerId);
68+
69+
$refresher = new TokenRefresher(
70+
httpClient: $this->httpClient,
71+
requestFactory: $this->requestFactory,
72+
streamFactory: $this->streamFactory,
73+
tokenEndpoint: $config['token_endpoint'] ?? '',
74+
clientId: $config['client_id'] ?? '',
75+
clientSecret: $config['client_secret'] ?? null,
76+
);
77+
78+
return new AuthenticatedHttpClient($this->httpClient, $tokenSet, $refresher);
79+
}
80+
}

src/Service/DefaultOAuthTokenService.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,13 @@
1717
namespace Horde\Horde\Service;
1818

1919
use Horde\Core\Service\Exception\OAuthTokenRefreshException;
20+
use Horde\Core\Service\NullScopeStrategy;
2021
use Horde\Core\Service\OAuthProviderConfigRepository;
2122
use Horde\Core\Service\OAuthTokenRepository;
2223
use Horde\Core\Service\OAuthTokenService;
24+
use Horde\Core\Service\ScopeCheckResult;
25+
use Horde\Core\Service\WantedScopes;
26+
use Horde\OAuth\Client\ScopeSet;
2327
use Horde\OAuth\Client\TokenRefresher;
2428
use Horde\OAuth\Client\TokenSet;
2529
use Psr\Http\Client\ClientInterface;
@@ -87,6 +91,31 @@ public function getTokenSet(string $userId, string $providerId): TokenSet
8791
return $this->repository->load($userId, $providerId);
8892
}
8993

94+
public function checkScopes(string $userId, string $providerId, WantedScopes $wanted): ScopeCheckResult
95+
{
96+
if (!$this->repository->exists($userId, $providerId)) {
97+
return ScopeCheckResult::NoToken;
98+
}
99+
100+
if ($wanted->isEmpty()) {
101+
return ScopeCheckResult::Sufficient;
102+
}
103+
104+
$tokenSet = $this->repository->load($userId, $providerId);
105+
106+
if ($tokenSet->scope === null) {
107+
return $wanted->nullStrategy === NullScopeStrategy::TreatAsSufficient
108+
? ScopeCheckResult::Sufficient
109+
: ScopeCheckResult::Insufficient;
110+
}
111+
112+
$granted = ScopeSet::fromSpaceSeparated($tokenSet->scope);
113+
114+
return $granted->hasAll($wanted->scopes)
115+
? ScopeCheckResult::Sufficient
116+
: ScopeCheckResult::Insufficient;
117+
}
118+
90119
private function refresh(string $providerId, TokenSet $tokenSet): TokenSet
91120
{
92121
try {

src/Settings/OAuthAccountController.php

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use Horde\OAuth\Client\OAuthFlowStore;
4141
use Horde\OAuth\Client\PkceGenerator;
4242
use Horde\OAuth\Client\ProviderConfig;
43+
use Horde\OAuth\Client\ScopeSet;
4344
use Horde_Notification_Handler;
4445
use Horde_Registry;
4546
use Horde_View;
@@ -167,6 +168,38 @@ private function connect(ServerRequestInterface $request, ?string $providerId):
167168
return $this->redirect($baseUrl . '/');
168169
}
169170

171+
$queryParams = $request->getQueryParams();
172+
$extraScopes = trim($queryParams['scopes'] ?? '');
173+
$returnUrl = trim($queryParams['return_url'] ?? '');
174+
$requestingApp = trim($queryParams['requesting_app'] ?? '');
175+
$userId = $request->getAttribute('HORDE_AUTHENTICATED_USER');
176+
177+
$defaultScopes = !empty($row['default_scopes']) ? explode(' ', $row['default_scopes']) : [];
178+
$neededScopes = $extraScopes !== ''
179+
? ScopeSet::fromSpaceSeparated($extraScopes)
180+
: new ScopeSet(...$defaultScopes);
181+
182+
$client = $this->buildOAuth2Client($row);
183+
184+
if ($extraScopes !== '' && $this->tokenService->hasTokens($userId, $providerId)) {
185+
$tokenSet = $this->tokenService->getTokenSet($userId, $providerId);
186+
$currentScopes = ScopeSet::fromSpaceSeparated($tokenSet->scope);
187+
188+
$result = $client->getIncrementalConsentUrl(
189+
currentScopes: $currentScopes,
190+
neededScopes: $neededScopes,
191+
);
192+
193+
if (!$result->consentNeeded) {
194+
$this->notification->push(_("Required scopes are already granted."), 'horde.message');
195+
return $this->redirect($returnUrl !== '' ? $returnUrl : $baseUrl . '/');
196+
}
197+
198+
$scopes = $result->mergedScopes->toArray();
199+
} else {
200+
$scopes = $neededScopes->toArray();
201+
}
202+
170203
$verifier = PkceGenerator::generateVerifier();
171204
$challenge = PkceGenerator::computeChallenge($verifier);
172205
$state = bin2hex(random_bytes(32));
@@ -177,11 +210,10 @@ private function connect(ServerRequestInterface $request, ?string $providerId):
177210
pkceVerifier: $verifier,
178211
flowType: 'account_link',
179212
createdAt: time(),
213+
redirectUrl: $returnUrl,
214+
requestingApp: $requestingApp,
180215
));
181216

182-
$client = $this->buildOAuth2Client($row);
183-
$scopes = !empty($row['default_scopes']) ? explode(' ', $row['default_scopes']) : [];
184-
185217
$authUrl = $client->getAuthorizationUrl(
186218
scopes: $scopes,
187219
state: $state,
@@ -368,6 +400,10 @@ private function handleAccountLinkCallback(
368400
);
369401
}
370402

403+
if ($flowData->redirectUrl !== '') {
404+
return $this->redirect($flowData->redirectUrl);
405+
}
406+
371407
return $this->redirect($baseUrl . '/');
372408
}
373409

0 commit comments

Comments
 (0)