Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
AzureAuthenticationController
100.00% covered (success)
100.00%
92 / 92
100.00% covered (success)
100.00%
7 / 7
29
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 authorize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 logout
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 callback
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
10
 buildAuthContext
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
9
 getCurrentUserService
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 loadAzureConfiguration
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Authentication Controller for Microsoft Entra ID
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 2022-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-22
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend;
21
22use Exception;
23use phpMyFAQ\Auth\AuthEntraId;
24use phpMyFAQ\Auth\EntraId\EntraIdSession;
25use phpMyFAQ\Auth\EntraId\JwksProvider;
26use phpMyFAQ\Auth\EntraId\OAuth;
27use phpMyFAQ\Enums\AuthenticationSourceType;
28use phpMyFAQ\Filter;
29use phpMyFAQ\User\CurrentUser;
30use Symfony\Component\HttpFoundation\RedirectResponse;
31use Symfony\Component\HttpFoundation\Request;
32use Symfony\Component\HttpFoundation\Response;
33use Symfony\Component\HttpFoundation\Session\Session;
34use Symfony\Component\HttpFoundation\Session\Storage\PhpBridgeSessionStorage;
35use Symfony\Component\Routing\Attribute\Route;
36use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
37
38final class AzureAuthenticationController extends AbstractFrontController
39{
40    public function __construct(
41        private readonly ?\Closure $authContextFactory = null,
42        private readonly ?\Closure $currentUserFactory = null,
43        private readonly ?\Closure $azureConfigLoader = null,
44    ) {
45        parent::__construct();
46    }
47
48    #[Route(path: '/auth/azure/authorize', name: 'public.azure.authorize', methods: ['GET'])]
49    #[Route(path: '/services/azure/authorize', name: 'public.azure.authorize_legacy_services', methods: ['GET'])]
50    public function authorize(): RedirectResponse
51    {
52        $this->loadAzureConfiguration();
53
54        try {
55            [$auth] = $this->buildAuthContext();
56            return $auth->authorize();
57        } catch (Exception $exception) {
58            $this->configuration
59                ->getLogger()
60                ->info(sprintf(
61                    'Entra ID Login failed: %s at line %d at %s',
62                    $exception->getMessage(),
63                    $exception->getLine(),
64                    $exception->getFile(),
65                ));
66
67            return new RedirectResponse($this->configuration->getDefaultUrl());
68        }
69    }
70
71    #[Route(path: '/auth/azure/logout', name: 'public.azure.logout', methods: ['GET'])]
72    #[Route(path: '/services/azure/logout', name: 'public.azure.logout_legacy_services', methods: ['GET'])]
73    public function logout(): RedirectResponse
74    {
75        $this->loadAzureConfiguration();
76
77        [$auth] = $this->buildAuthContext();
78        return $auth->logout();
79    }
80
81    /**
82     * @throws Exception
83     */
84    #[Route(path: '/auth/azure/callback', name: 'public.azure.callback', methods: ['GET'])]
85    #[Route(path: '/services/azure/callback', name: 'public.azure.callback_legacy_services', methods: ['GET'])]
86    #[Route(path: '/services/azure/callback.php', name: 'public.azure.callback_legacy_php', methods: ['GET'])]
87    public function callback(Request $request): Response
88    {
89        $this->loadAzureConfiguration();
90
91        [$auth, $oAuth, $entraIdSession] = $this->buildAuthContext();
92
93        $code = Filter::filterVar($request->query->get('code'), FILTER_SANITIZE_SPECIAL_CHARS, '');
94        $errorParam = Filter::filterVar($request->query->get('error'), FILTER_SANITIZE_SPECIAL_CHARS, '');
95        $error = Filter::filterVar($request->query->get('error_description'), FILTER_SANITIZE_SPECIAL_CHARS, '');
96
97        if ($errorParam !== '' || $error !== '') {
98            $this->configuration
99                ->getLogger()
100                ->warning(sprintf(
101                    'Azure callback error: %s',
102                    trim($errorParam . ($error !== '' ? ': ' . $error : '')),
103                ));
104            return new RedirectResponse($this->configuration->getDefaultUrl());
105        }
106
107        $redirect = new RedirectResponse($this->configuration->getDefaultUrl());
108
109        if (!$entraIdSession->getCurrentSessionKey()) {
110            return $redirect;
111        }
112
113        try {
114            $token = $oAuth->getOAuthToken($code);
115            $accessToken = $token->access_token ?? null;
116            $refreshToken = $token->refresh_token ?? null;
117            $oAuth
118                ->setToken($token)
119                ->setAccessToken($accessToken === null ? null : (string) $accessToken)
120                ->setRefreshToken($refreshToken === null ? null : (string) $refreshToken);
121
122            if (!$auth->isValidLogin($oAuth->getMail())) {
123                return new Response('Login not valid.');
124            }
125
126            if (!$auth->checkCredentials($oAuth->getMail(), '')) {
127                return new Response('Credentials not valid.');
128            }
129
130            $user = $this->getCurrentUserService();
131            $user->getUserByLogin($oAuth->getMail());
132            $user->setLoggedIn(true);
133            $user->setAuthSource(AuthenticationSourceType::AUTH_AZURE->value);
134            $user->updateSessionId(true);
135            $user->saveToSession();
136            $user->setTokenData([
137                'refresh_token' => $oAuth->getRefreshToken() ?? '',
138                'access_token' => $oAuth->getAccessToken() ?? '',
139                'code_verifier' => (string) $entraIdSession->get(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER),
140                'jwt' => $oAuth->getToken(),
141            ]);
142            $user->setSuccess(true);
143
144            return $redirect;
145        } catch (TransportExceptionInterface|Exception $exception) {
146            return new Response(sprintf(
147                'Entra ID Login failed: %s at line %d at %s',
148                $exception->getMessage(),
149                $exception->getLine(),
150                $exception->getFile(),
151            ));
152        }
153    }
154
155    /**
156     * @return array{0: AuthEntraId, 1: OAuth, 2: EntraIdSession}
157     */
158    protected function buildAuthContext(): array
159    {
160        if ($this->authContextFactory instanceof \Closure) {
161            $authContext = ($this->authContextFactory)();
162            $entraAuth = is_array($authContext) ? $authContext[0] ?? null : null;
163            $entraOAuth = is_array($authContext) ? $authContext[1] ?? null : null;
164            $entraSession = is_array($authContext) ? $authContext[2] ?? null : null;
165            if (
166                $entraAuth instanceof AuthEntraId
167                && $entraOAuth instanceof OAuth
168                && $entraSession instanceof EntraIdSession
169            ) {
170                return [$entraAuth, $entraOAuth, $entraSession];
171            }
172        }
173
174        // Use a bridge session per request to preserve legacy Azure flow behavior.
175        $session = new Session(new PhpBridgeSessionStorage());
176        if (!$session->isStarted()) {
177            $session->start();
178        }
179
180        $entraIdSession = new EntraIdSession($this->configuration, $session);
181        $oAuth = new OAuth($this->configuration, $entraIdSession, new JwksProvider());
182        $auth = new AuthEntraId($this->configuration, $oAuth);
183
184        return [$auth, $oAuth, $entraIdSession];
185    }
186
187    protected function getCurrentUserService(): CurrentUser
188    {
189        if ($this->currentUserFactory instanceof \Closure) {
190            $currentUser = ($this->currentUserFactory)();
191            if ($currentUser instanceof CurrentUser) {
192                return $currentUser;
193            }
194        }
195
196        return $this->currentUser;
197    }
198
199    protected function loadAzureConfiguration(): void
200    {
201        if ($this->azureConfigLoader instanceof \Closure) {
202            ($this->azureConfigLoader)();
203            return;
204        }
205
206        if (defined('AAD_OAUTH_CLIENTID')) {
207            return;
208        }
209
210        require (string) PMF_CONFIG_DIR . '/azure.php';
211    }
212}