Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (success)
80.00%
44 / 55
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthEntraId
80.00% covered (success)
80.00%
44 / 55
77.78% covered (warning)
77.78%
7 / 9
15.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 create
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
4.31
 update
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkCredentials
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
 isValidLogin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 authorize
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 logout
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createOAuthChallenge
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3/**
4 * Manages user authentication with 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     2022-09-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth;
21
22use phpMyFAQ\Auth;
23use phpMyFAQ\Auth\EntraId\EntraIdSession;
24use phpMyFAQ\Auth\EntraId\OAuth;
25use phpMyFAQ\Configuration;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Enums\AuthenticationSourceType;
28use phpMyFAQ\User;
29use SensitiveParameter;
30use Symfony\Component\HttpFoundation\RedirectResponse;
31
32/**q
33 * Class AuthEntraId
34 *
35 * @package phpMyFAQ\Auth
36 */
37class AuthEntraId extends Auth implements AuthDriverInterface
38{
39    private string $oAuthVerifier = '';
40
41    private string $oAuthChallenge;
42
43    private const string ENTRAID_CHALLENGE_METHOD = 'S256';
44
45    private const string ENTRAID_LOGOUT_URL = 'https://login.microsoftonline.com/common/wsfederation?wa=wsignout1.0';
46
47    /**
48     * @inheritDoc
49     */
50    public function __construct(
51        Configuration $configuration,
52        private readonly OAuth $oAuth,
53    ) {
54        $this->configuration = $configuration;
55
56        parent::__construct($configuration);
57    }
58
59    /**
60     * @inheritDoc
61     * @throws Exception
62     */
63    public function create(string $login, #[SensitiveParameter] string $password, string $domain = ''): mixed
64    {
65        $result = false;
66        $user = new User($this->configuration);
67
68        try {
69            $result = $user->createUser($login, '', $domain);
70        } catch (\Exception $exception) {
71            $this->configuration->getLogger()->info($exception->getMessage());
72        }
73
74        $user->setStatus('active');
75        $user->setAuthSource(AuthenticationSourceType::AUTH_AZURE->value);
76
77        // Set user information from JWT
78        $user->setUserData([
79            'display_name' => $this->oAuth->getName(),
80            'email' => $this->oAuth->getMail(),
81        ]);
82
83        return $result;
84    }
85
86    /**
87     * @inheritDoc
88     */
89    public function update(string $login, #[SensitiveParameter] string $password): bool
90    {
91        return true;
92    }
93
94    /**
95     * @inheritDoc
96     */
97    public function delete(string $login): bool
98    {
99        return true;
100    }
101
102    /**
103     * @inheritDoc
104     * @throws Exception
105     */
106    public function checkCredentials(
107        string $login,
108        #[SensitiveParameter]
109        string $password,
110        ?array $optionalData = [],
111    ): bool {
112        $this->create($login, '');
113        return true;
114    }
115
116    /**
117     * @inheritDoc
118     */
119    public function isValidLogin(string $login, ?array $optionalData = []): int
120    {
121        if ($login === $this->oAuth->getMail()) {
122            return 1;
123        }
124
125        return 0;
126    }
127
128    /**
129     * Method to authorize against Entra ID
130     *
131     * @throws \Exception
132     */
133    public function authorize(): RedirectResponse
134    {
135        $this->createOAuthChallenge();
136        $this->oAuth->getEntraIdSession()->setCurrentSessionKey();
137        $this->oAuth->getEntraIdSession()->set(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER, $this->oAuthVerifier);
138        $this->oAuth->getEntraIdSession()->setCookie(
139            EntraIdSession::ENTRA_ID_OAUTH_VERIFIER,
140            $this->oAuthVerifier,
141            7200,
142            false,
143        );
144
145        $oAuthURL = sprintf(
146            'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize'
147            . '?response_type=code&client_id=%s&redirect_uri=%s&scope=%s&code_challenge=%s&code_challenge_method=%s',
148            AAD_OAUTH_TENANTID,
149            AAD_OAUTH_CLIENTID,
150            urlencode($this->configuration->getDefaultUrl() . 'services/azure/callback.php'),
151            AAD_OAUTH_SCOPE,
152            $this->oAuthChallenge,
153            self::ENTRAID_CHALLENGE_METHOD,
154        );
155
156        return new RedirectResponse($oAuthURL);
157    }
158
159    /**
160     * Logout
161     *
162     */
163    public function logout(): RedirectResponse
164    {
165        return new RedirectResponse(self::ENTRAID_LOGOUT_URL);
166    }
167
168    /**
169     * Method to generate code verifier and code challenge for oAuth login.
170     * See RFC7636 for details.
171     *
172     * @throws \Exception
173     */
174    private function createOAuthChallenge(): void
175    {
176        $verifier = $this->oAuthVerifier;
177
178        if ($this->oAuthVerifier === '' || $this->oAuthVerifier === '0') {
179            $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~';
180            $charLen = strlen($chars) - 1;
181            $verifier = '';
182
183            for ($i = 0; $i < 128; ++$i) {
184                $verifier .= $chars[random_int(0, $charLen)];
185            }
186
187            $this->oAuthVerifier = $verifier;
188        }
189
190        $this->oAuthChallenge = str_replace(
191            search: '=',
192            replace: '',
193            subject: strtr(string: base64_encode(pack('H*', hash('sha256', $verifier))), from: '+/', to: '-_'),
194        );
195    }
196}