Lines 70.31% 90 / 128
Methods 80.00% 8 / 10
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 setTokenIssuer 100.00% 1 / 1 100.00% 1 / 1 1
 setAuthorizationCompleter 100.00% 1 / 1 100.00% 1 / 1 1
 issueToken 61.11% 22 / 36 0.00% 0 / 1 11.76
 buildLeagueAuthorizationServer 100.00% 25 / 25 100.00% 1 / 1 3
 isEnabled 100.00% 2 / 2 100.00% 1 / 1 4
 completeAuthorization 40.00% 16 / 40 0.00% 0 / 1 17.58
 getConfigString 100.00% 2 / 2 100.00% 1 / 1 2
 parseInterval 100.00% 7 / 7 100.00% 1 / 1 3
 toPsr7Request 100.00% 13 / 13 100.00% 1 / 1 3
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}