Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.70% covered (success)
98.70%
76 / 77
80.00% covered (success)
80.00%
4 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
OAuth2Controller
98.70% covered (success)
98.70%
76 / 77
80.00% covered (success)
80.00%
4 / 5
26
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
 setAuthorizationServer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 token
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
5
 authorize
98.18% covered (success)
98.18%
54 / 55
0.00% covered (danger)
0.00%
0 / 1
17
 getAuthorizationServer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * OAuth2 endpoints controller.
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
11declare(strict_types=1);
12
13namespace phpMyFAQ\Controller\Api;
14
15use phpMyFAQ\Auth\OAuth2\AuthorizationServer as OAuth2AuthorizationServer;
16use Symfony\Component\HttpFoundation\JsonResponse;
17use Symfony\Component\HttpFoundation\RedirectResponse;
18use Symfony\Component\HttpFoundation\Request;
19use Symfony\Component\HttpFoundation\Response;
20use Symfony\Component\Routing\Attribute\Route;
21
22final class OAuth2Controller extends AbstractApiController
23{
24    private ?OAuth2AuthorizationServer $authorizationServer = null;
25
26    public function __construct(
27        private readonly OAuth2AuthorizationServer $oauth2AuthorizationServer,
28    ) {
29        parent::__construct();
30    }
31
32    public function setAuthorizationServer(OAuth2AuthorizationServer $authorizationServer): void
33    {
34        $this->authorizationServer = $authorizationServer;
35    }
36
37    /**
38     * OAuth2 token endpoint.
39     */
40    #[Route(path: 'oauth/token', name: 'api.oauth2.token', methods: ['POST'])]
41    public function token(Request $request): JsonResponse
42    {
43        $authorizationServer = $this->getAuthorizationServer();
44
45        try {
46            $result = $authorizationServer->issueToken($request);
47            return $this->json($result['body'], $result['status'], $result['headers'] ?? []);
48        } catch (\RuntimeException $exception) {
49            $statusCode = (int) $exception->getCode();
50            if ($statusCode < 400 || $statusCode > 599) {
51                $statusCode = Response::HTTP_SERVICE_UNAVAILABLE;
52            }
53
54            if ($statusCode >= 500) {
55                $this->configuration->getLogger()->error('OAuth2 token error: ' . $exception->getMessage());
56
57                return $this->json([
58                    'error' => 'oauth2_unavailable',
59                    'error_description' => 'Internal server error',
60                ], $statusCode);
61            }
62
63            return $this->json([
64                'error' => 'oauth2_unavailable',
65                'error_description' => $exception->getMessage(),
66            ], $statusCode);
67        }
68    }
69
70    /**
71     * OAuth2 authorization endpoint for authorization_code grant.
72     *
73     * @throws \Exception
74     */
75    #[Route(path: 'oauth/authorize', name: 'api.oauth2.authorize', methods: ['GET', 'POST'])]
76    public function authorize(Request $request): Response
77    {
78        if (!$this->currentUser?->isLoggedIn()) {
79            return $this->json(['error' => 'access_denied', 'error_description' => 'User is not authenticated.'], 401);
80        }
81
82        if ($request->getMethod() === 'GET') {
83            return $this->json([
84                'error' => 'consent_required',
85                'error_description' => 'Submit a POST request with an explicit approve parameter and CSRF token.',
86            ], Response::HTTP_BAD_REQUEST);
87        }
88
89        $csrf = $request->headers->get('X-CSRF-Token') ?? $request->request->get('csrf', '');
90        if (!$this->verifySessionCsrfToken('oauth2-authorize', (string) $csrf)) {
91            return $this->json([
92                'error' => 'invalid_request',
93                'error_description' => 'CSRF token validation failed.',
94            ], Response::HTTP_FORBIDDEN);
95        }
96
97        if (!$request->request->has('approve')) {
98            return $this->json([
99                'error' => 'invalid_request',
100                'error_description' => 'Missing required approve parameter.',
101            ], Response::HTTP_BAD_REQUEST);
102        }
103
104        $isApproved = filter_var($request->request->get('approve'), FILTER_VALIDATE_BOOLEAN);
105
106        try {
107            $result = $this->getAuthorizationServer()->completeAuthorization(
108                $request,
109                (string) $this->currentUser->getUserId(),
110                $isApproved,
111            );
112        } catch (\RuntimeException $exception) {
113            $statusCode = (int) $exception->getCode();
114            if ($statusCode < 400 || $statusCode > 599) {
115                $statusCode = Response::HTTP_SERVICE_UNAVAILABLE;
116            }
117
118            if ($statusCode >= 500) {
119                $this->configuration->getLogger()->error('OAuth2 authorization error: ' . $exception->getMessage());
120
121                return $this->json([
122                    'error' => 'oauth2_unavailable',
123                    'error_description' => 'Internal server error',
124                ], $statusCode);
125            }
126
127            return $this->json([
128                'error' => 'oauth2_unavailable',
129                'error_description' => $exception->getMessage(),
130            ], $statusCode);
131        }
132
133        $locationHeader = null;
134        if (
135            array_key_exists('headers', $result)
136            && is_array($result['headers'])
137            && array_key_exists('Location', $result['headers'])
138        ) {
139            $locationHeader = $result['headers']['Location'];
140        }
141
142        if (is_string($locationHeader)) {
143            return new RedirectResponse($locationHeader, $result['status'], $result['headers'] ?? []);
144        }
145
146        $contentType = $result['headers']['Content-Type'] ?? '';
147        if (str_contains($contentType, 'application/json')) {
148            $data = json_decode(is_string($result['body'] ?? null) ? $result['body'] : '', associative: true);
149            if (is_array($data)) {
150                return $this->json($data, $result['status'], $result['headers'] ?? []);
151            }
152        }
153
154        return new Response(
155            is_string($result['body'] ?? null) ? $result['body'] : '',
156            $result['status'],
157            $result['headers'] ?? [],
158        );
159    }
160
161    private function getAuthorizationServer(): OAuth2AuthorizationServer
162    {
163        if ($this->authorizationServer instanceof OAuth2AuthorizationServer) {
164            return $this->authorizationServer;
165        }
166
167        return $this->oauth2AuthorizationServer;
168    }
169}