Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.31% covered (warning)
70.31%
90 / 128
80.00% covered (success)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthorizationServer
70.31% covered (warning)
70.31%
90 / 128
80.00% covered (success)
80.00%
8 / 10
61.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTokenIssuer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAuthorizationCompleter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 issueToken
61.11% covered (warning)
61.11%
22 / 36
0.00% covered (danger)
0.00%
0 / 1
11.76
 buildLeagueAuthorizationServer
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 isEnabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 completeAuthorization
40.00% covered (danger)
40.00%
16 / 40
0.00% covered (danger)
0.00%
0 / 1
17.58
 getConfigString
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 parseInterval
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 toPsr7Request
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * OAuth2 authorization server facade.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth\OAuth2;
21
22use DateInterval;
23use GuzzleHttp\Psr7\Response as Psr7Response;
24use GuzzleHttp\Psr7\ServerRequest as Psr7ServerRequest;
25use League\OAuth2\Server\AuthorizationServer as LeagueAuthorizationServer;
26use League\OAuth2\Server\CryptKey;
27use League\OAuth2\Server\Exception\OAuthServerException;
28use League\OAuth2\Server\Grant\AuthCodeGrant;
29use League\OAuth2\Server\Grant\ClientCredentialsGrant;
30use phpMyFAQ\Auth\OAuth2\Entity\UserEntity;
31use phpMyFAQ\Auth\OAuth2\Repository\AccessTokenRepository;
32use phpMyFAQ\Auth\OAuth2\Repository\AuthCodeRepository;
33use phpMyFAQ\Auth\OAuth2\Repository\ClientRepository;
34use phpMyFAQ\Auth\OAuth2\Repository\RefreshTokenRepository;
35use phpMyFAQ\Auth\OAuth2\Repository\ScopeRepository;
36use phpMyFAQ\Configuration;
37use RuntimeException;
38use Symfony\Component\HttpFoundation\Request;
39use Symfony\Component\HttpFoundation\Response;
40
41final class AuthorizationServer
42{
43    /** @var callable(Request): array{body: array<array-key, mixed>|string, status: int, headers?: array<string, string>}|null */
44    private $tokenIssuer = null;
45    /** @var callable(Request, string, bool): array{body: array<array-key, mixed>|string, status: int, headers?: array<string, string>}|null */
46    private $authorizationCompleter = null;
47
48    public function __construct(
49        private readonly Configuration $configuration,
50    ) {
51    }
52
53    /**
54     * Allows integration code to provide a token issuer implementation.
55     *
56     * @param callable(Request): array{body: array<array-key, mixed>|string, status: int, headers?: array<string, string>} $issuer
57     */
58    public function setTokenIssuer(callable $issuer): void
59    {
60        $this->tokenIssuer = $issuer;
61    }
62
63    /**
64     * Allows integration code to provide an authorization completer implementation.
65     *
66     * @param callable(Request, string, bool): array{body: array<array-key, mixed>|string, status: int, headers?: array<string, string>} $completer
67     */
68    public function setAuthorizationCompleter(callable $completer): void
69    {
70        $this->authorizationCompleter = $completer;
71    }
72
73    /**
74     * Issues an OAuth2 access token response payload.
75     *
76     * @return array{body: array<array-key, mixed>|string, status: int, headers?: array<string, string>}
77     */
78    public function issueToken(Request $request): array
79    {
80        if (is_callable($this->tokenIssuer)) {
81            return ($this->tokenIssuer)($request);
82        }
83
84        if (!class_exists(\League\OAuth2\Server\AuthorizationServer::class)) {
85            throw new RuntimeException(
86                'OAuth2 server dependency not installed. Please add league/oauth2-server to enable OAuth2 token issuing.',
87            );
88        }
89
90        if (!$this->isEnabled()) {
91            throw new RuntimeException('OAuth2 authorization server is disabled.', Response::HTTP_SERVICE_UNAVAILABLE);
92        }
93
94        try {
95            $psrRequest = $this->toPsr7Request($request);
96            $psrResponse = new Psr7Response();
97            $leagueResponse = $this->buildLeagueAuthorizationServer()->respondToAccessTokenRequest(
98                $psrRequest,
99                $psrResponse,
100            );
101
102            $body = json_decode((string) $leagueResponse->getBody(), associative: true);
103            if (!is_array($body)) {
104                $body = ['error' => 'server_error', 'error_description' => 'Invalid OAuth2 token response body'];
105            }
106
107            $headers = [];
108            foreach ($leagueResponse->getHeaders() as $headerName => $values) {
109                $headers[(string) $headerName] = implode(', ', $values);
110            }
111
112            return [
113                'body' => $body,
114                'status' => $leagueResponse->getStatusCode(),
115                'headers' => $headers,
116            ];
117        } catch (OAuthServerException $exception) {
118            return [
119                'body' => $exception->getPayload(),
120                'status' => $exception->getHttpStatusCode(),
121                'headers' => $exception->getHttpHeaders(),
122            ];
123        } catch (\Throwable $exception) {
124            throw new RuntimeException(
125                'OAuth2 token issuance failed: ' . $exception->getMessage(),
126                Response::HTTP_INTERNAL_SERVER_ERROR,
127            );
128        }
129    }
130
131    private function buildLeagueAuthorizationServer(): LeagueAuthorizationServer
132    {
133        $privateKeyPath = $this->getConfigString('oauth2.privateKeyPath');
134        $encryptionKey = $this->getConfigString('oauth2.encryptionKey');
135
136        if ($privateKeyPath === '' || $encryptionKey === '') {
137            throw new RuntimeException(
138                'OAuth2 keys are not configured. Set oauth2.privateKeyPath and oauth2.encryptionKey.',
139            );
140        }
141
142        $server = new LeagueAuthorizationServer(
143            new ClientRepository($this->configuration),
144            new AccessTokenRepository($this->configuration),
145            new ScopeRepository($this->configuration),
146            new CryptKey($privateKeyPath),
147            $encryptionKey,
148        );
149
150        $accessTokenTtl = $this->parseInterval($this->getConfigString('oauth2.accessTokenTTL'), 'PT1H');
151        $refreshTokenTtl = $this->parseInterval($this->getConfigString('oauth2.refreshTokenTTL'), 'P1M');
152        $authCodeTtl = $this->parseInterval($this->getConfigString('oauth2.authCodeTTL'), 'PT10M');
153
154        $server->enableGrantType(new ClientCredentialsGrant(), $accessTokenTtl);
155
156        $authCodeGrant = new AuthCodeGrant(
157            new AuthCodeRepository($this->configuration),
158            new RefreshTokenRepository($this->configuration),
159            $authCodeTtl,
160        );
161        $authCodeGrant->setRefreshTokenTTL($refreshTokenTtl);
162        $server->enableGrantType($authCodeGrant, $accessTokenTtl);
163
164        return $server;
165    }
166
167    public function isEnabled(): bool
168    {
169        $value = $this->configuration->get('oauth2.enable');
170        return $value === true || $value === 'true' || $value === 1 || $value === '1';
171    }
172
173    /**
174     * Completes an authorization request and returns the response payload.
175     *
176     * @return array{body: array<array-key, mixed>|string, status: int, headers?: array<string, string>}
177     */
178    public function completeAuthorization(Request $request, string $userId, bool $approved): array
179    {
180        if (is_callable($this->authorizationCompleter)) {
181            return ($this->authorizationCompleter)($request, $userId, $approved);
182        }
183
184        try {
185            $psrRequest = $this->toPsr7Request($request);
186            $server = $this->buildLeagueAuthorizationServer();
187            $authorizationRequest = $server->validateAuthorizationRequest($psrRequest);
188            if ($userId === '') {
189                throw new RuntimeException(
190                    'OAuth2 authorization requires an authenticated user id.',
191                    Response::HTTP_INTERNAL_SERVER_ERROR,
192                );
193            }
194
195            $user = new UserEntity();
196            $user->setIdentifier($userId);
197            $authorizationRequest->setUser($user);
198            $authorizationRequest->setAuthorizationApproved($approved);
199
200            $response = $server->completeAuthorizationRequest($authorizationRequest, new Psr7Response());
201
202            $headers = [];
203            foreach ($response->getHeaders() as $headerName => $values) {
204                $headers[(string) $headerName] = implode(', ', $values);
205            }
206
207            $body = json_decode((string) $response->getBody(), associative: true);
208            if (!is_array($body)) {
209                $body = [
210                    'error' => 'server_error',
211                    'error_description' => 'Invalid OAuth2 authorization response body',
212                ];
213            }
214
215            return [
216                'body' => $body,
217                'status' => $response->getStatusCode(),
218                'headers' => $headers,
219            ];
220        } catch (OAuthServerException $exception) {
221            return [
222                'body' => $exception->getPayload(),
223                'status' => $exception->getHttpStatusCode(),
224                'headers' => $exception->getHttpHeaders(),
225            ];
226        } catch (\Throwable $exception) {
227            throw new RuntimeException(
228                'OAuth2 authorization failed: ' . $exception->getMessage(),
229                Response::HTTP_INTERNAL_SERVER_ERROR,
230            );
231        }
232    }
233
234    private function getConfigString(string $key): string
235    {
236        $value = $this->configuration->get($key);
237        return is_string($value) ? trim($value) : '';
238    }
239
240    private function parseInterval(string $value, string $fallback): DateInterval
241    {
242        try {
243            if ($value !== '') {
244                return new DateInterval($value);
245            }
246        } catch (\Throwable $exception) {
247            $this->configuration
248                ->getLogger()
249                ->notice(sprintf('Invalid OAuth interval "%s", using fallback "%s".', $value, $fallback));
250        }
251
252        return new DateInterval($fallback);
253    }
254
255    private function toPsr7Request(Request $request): Psr7ServerRequest
256    {
257        $headers = [];
258        foreach ($request->headers->all() as $name => $values) {
259            $headers[$name] = implode(', ', $values);
260        }
261
262        $psrRequest = new Psr7ServerRequest(
263            $request->getMethod(),
264            $request->getUri(),
265            $headers,
266            $request->getContent(),
267        );
268
269        $parsedBody = $request->request->all();
270        if ($parsedBody !== []) {
271            $psrRequest = $psrRequest->withParsedBody($parsedBody);
272        }
273
274        return $psrRequest->withQueryParams($request->query->all());
275    }
276}