Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 195
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
KeycloakInstance
0.00% covered (danger)
0.00%
0 / 195
0.00% covered (danger)
0.00%
0 / 12
1640
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getProvider
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doLogin
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 doLogout
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 writeNewAccessTokenIfExpired
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 validateAccess
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
132
 validateOwnerData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getAccessToken
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 writeTokenToSession
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 writeDeleteSession
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 readTokenDataFromSession
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getSessionKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace BO\Slim\Middleware\OAuth;
4
5use Psr\Http\Message\ServerRequestInterface;
6use Psr\Http\Message\ResponseInterface;
7use League\OAuth2\Client\Token\AccessToken;
8use BO\Slim\Middleware\OAuth\Keycloak\Provider;
9use BO\Zmsclient\OAuthService;
10
11/**
12 * @SuppressWarnings(PHPMD)
13 */
14class KeycloakInstance
15{
16    protected Provider $provider;
17    protected OAuthService $oauthService;
18
19    /** @psalm-api Instantiated via class-string in OAuthMiddleware. */
20    public function __construct(?OAuthService $oauthService = null)
21    {
22        $this->oauthService = $oauthService ?: new OAuthService(\App::$http, \App::CONFIG_SECURE_TOKEN);
23        $this->provider = new Provider(null, $this->oauthService);
24    }
25
26    public function getProvider(): Provider
27    {
28        return $this->provider;
29    }
30
31    public function doLogin(ServerRequestInterface $request): void
32    {
33        \App::$log->info('OIDC login attempt', [
34            'event' => 'oauth_login_start',
35            'timestamp' => date('c')
36        ]);
37
38        try {
39            $accessToken = $this->getAccessToken($request->getQueryParams()["code"] ?? '');
40            $this->validateAccess($accessToken);
41            $ownerInputData =  $this->provider->getResourceOwnerData($accessToken);
42            $this->validateOwnerData((array) $ownerInputData);
43
44            $existingKey = \BO\Zmsclient\Auth::getKey();
45            if ($existingKey !== null && $existingKey !== '') {
46                \App::$log->info('Clearing existing session', [
47                    'event' => 'oauth_session_clear',
48                    'timestamp' => date('c')
49                ]);
50                $this->writeDeleteSession();
51            }
52
53            $this->writeTokenToSession($accessToken);
54            $this->oauthService->authenticateWorkstation($ownerInputData, \BO\Zmsclient\Auth::getKey());
55
56            \App::$log->info('OIDC login successful', [
57                'event' => 'oauth_login_success',
58                'timestamp' => date('c')
59            ]);
60        } catch (\BO\Zmsclient\Exception $exception) {
61            \App::$log->error('OIDC login failed', [
62                'event' => 'oauth_login_error',
63                'timestamp' => date('c'),
64                'error' => $exception->getMessage()
65            ]);
66            $this->writeDeleteSession();
67            \BO\Zmsclient\Auth::removeKey();
68            \BO\Zmsclient\Auth::removeOidcProvider();
69            throw $exception;
70        }
71    }
72
73    public function doLogout(ResponseInterface $response): ResponseInterface
74    {
75        $this->writeDeleteSession();
76        $realmData = $this->provider->getBasicOptionsFromJsonFile();
77        return $response->withStatus(301)->withHeader('Location', $realmData['logoutUri']);
78    }
79
80    public function writeNewAccessTokenIfExpired(): bool
81    {
82        try {
83            $accessTokenData = $this->readTokenDataFromSession();
84            $accessTokenData = (is_array($accessTokenData)) ? $accessTokenData : [];
85            $existingAccessToken = new AccessToken($accessTokenData);
86            if ($existingAccessToken->hasExpired()) {
87                $newAccessToken = $this->provider->getAccessToken('refresh_token', [
88                    'refresh_token' => $existingAccessToken->getRefreshToken()
89                ]);
90                if (!$newAccessToken instanceof AccessToken) {
91                    return false;
92                }
93                $this->writeDeleteSession();
94                $this->writeTokenToSession($newAccessToken);
95            }
96        } catch (\Exception $exception) {
97            return false;
98        }
99        return true;
100    }
101
102    /**
103     * @return void
104     */
105    private function validateAccess(AccessToken $token): void
106    {
107        \App::$log->info('Validating OIDC token', [
108            'event' => 'oauth_token_validation',
109            'timestamp' => date('c')
110        ]);
111
112        list($header, $payload, $signature) = explode('.', $token->getToken());
113
114        if (empty($header)) {
115            \App::$log->error('Token validation failed', [
116                'event' => 'oauth_token_validation_failed',
117                'timestamp' => date('c'),
118                'reason' => 'missing_header'
119            ]);
120            throw new \BO\Slim\Exception\OAuthFailed();
121        }
122        if (empty($payload)) {
123            \App::$log->error('Token validation failed', [
124                'event' => 'oauth_token_validation_failed',
125                'timestamp' => date('c'),
126                'reason' => 'missing_payload'
127            ]);
128            throw new \BO\Slim\Exception\OAuthFailed();
129        }
130        if (empty($signature)) {
131            \App::$log->error('Token validation failed', [
132                'event' => 'oauth_token_validation_failed',
133                'timestamp' => date('c'),
134                'reason' => 'missing_signature'
135            ]);
136            throw new \BO\Slim\Exception\OAuthFailed();
137        }
138
139        $realmData = $this->provider->getBasicOptionsFromJsonFile();
140
141        // Fix: Properly handle base64url encoding before JSON decoding
142        $payload = str_replace(['-', '_'], ['+', '/'], $payload);
143        $payload = base64_decode($payload . str_repeat('=', 4 - (strlen($payload) % 4)));
144        $accessTokenPayload = json_decode($payload, true);
145
146        $clientRoles = array();
147
148        if ($accessTokenPayload === null) {
149            \App::$log->error('Token validation failed', [
150                'event' => 'oauth_token_validation_failed',
151                'timestamp' => date('c'),
152                'reason' => 'invalid_payload_json',
153                'error' => json_last_error_msg()
154            ]);
155            throw new \BO\Slim\Exception\OAuthFailed();
156        }
157
158        if (!isset($accessTokenPayload['resource_access']) || !is_array($accessTokenPayload['resource_access'])) {
159            \App::$log->error('Token validation failed', [
160                'event' => 'oauth_token_validation_failed',
161                'timestamp' => date('c'),
162                'reason' => 'invalid_resource_access',
163                'has_resource_access' => isset($accessTokenPayload['resource_access']),
164                'resource_access_type' => gettype($accessTokenPayload['resource_access'] ?? null)
165            ]);
166            throw new \BO\Slim\Exception\OAuthFailed();
167        }
168
169        if (!isset($accessTokenPayload['resource_access'][\App::IDENTIFIER])) {
170            \App::$log->error('Token validation failed', [
171                'event' => 'oauth_token_validation_failed',
172                'timestamp' => date('c'),
173                'reason' => 'missing_app_identifier',
174                'app_identifier' => \App::IDENTIFIER,
175                'available_resources' => array_keys($accessTokenPayload['resource_access'])
176            ]);
177            throw new \BO\Slim\Exception\OAuthFailed();
178        }
179
180        $resourceAccess = $accessTokenPayload['resource_access'];
181        $appIdentifierRoles = $resourceAccess[\App::IDENTIFIER]['roles'] ?? null;
182
183        if (!is_array($appIdentifierRoles)) {
184            \App::$log->error('Token validation failed', [
185                'event' => 'oauth_token_validation_failed',
186                'timestamp' => date('c'),
187                'reason' => 'invalid_roles',
188                'has_roles' => isset($resourceAccess[\App::IDENTIFIER]['roles']),
189                'roles_type' => gettype($appIdentifierRoles)
190            ]);
191            throw new \BO\Slim\Exception\OAuthFailed();
192        }
193
194        if (is_array($accessTokenPayload['resource_access'])) {
195            $clientRoles = array_values($accessTokenPayload['resource_access'][\App::IDENTIFIER]['roles']);
196        }
197
198        if (!in_array($realmData['accessRole'], $clientRoles)) {
199            \App::$log->error('Token validation failed', [
200                'event' => 'oauth_token_validation_failed',
201                'timestamp' => date('c'),
202                'reason' => 'missing_required_role',
203                'required_role' => $realmData['accessRole'],
204                'available_roles' => $clientRoles
205            ]);
206            throw new \BO\Slim\Exception\OAuthFailed();
207        }
208
209        \App::$log->info('Token validation successful', [
210            'event' => 'oauth_token_validation_success',
211            'timestamp' => date('c')
212        ]);
213    }
214
215    /**
216     * @return void
217     */
218    private function validateOwnerData(array $ownerInputData): void
219    {
220        $config = $this->oauthService->readConfig();
221        if (! \array_key_exists('email', $ownerInputData) && 1 == $config->getPreference('oidc', 'onlyVerifiedMail')) {
222            throw new \BO\Slim\Exception\OAuthPreconditionFailed();
223        }
224    }
225
226    private function getAccessToken(string $code): AccessToken
227    {
228        \App::$log->info('Getting access token', [
229            'event' => 'oauth_get_token',
230            'timestamp' => date('c')
231        ]);
232
233        try {
234            $accessToken = $this->provider->getAccessToken('authorization_code', ['code' => $code]);
235            if (!$accessToken instanceof AccessToken) {
236                throw new \BO\Slim\Exception\OAuthFailed();
237            }
238            \App::$log->info('Access token obtained', [
239                'event' => 'oauth_get_token_success',
240                'timestamp' => date('c')
241            ]);
242            return $accessToken;
243        } catch (\Exception $exception) {
244            \App::$log->error('Failed to get access token', [
245                'event' => 'oauth_get_token_error',
246                'timestamp' => date('c'),
247                'error' => $exception->getMessage(),
248                'exception_class' => get_class($exception)
249            ]);
250            if ('League\OAuth2\Client\Provider\Exception\IdentityProviderException' === get_class($exception)) {
251                throw new \BO\Slim\Exception\OAuthFailed();
252            }
253            throw $exception;
254        }
255    }
256
257    private function writeTokenToSession(AccessToken $token): void
258    {
259        \App::$log->info('Writing token to session', [
260            'event' => 'oauth_write_token',
261            'timestamp' => date('c')
262        ]);
263
264        $sessionKey = $this->getSessionKey();
265        if ($sessionKey === null) {
266            throw new \BO\Slim\Exception\OAuthFailed();
267        }
268        $realmData = $this->provider->getBasicOptionsFromJsonFile();
269        $sessionHandler = (new \BO\Zmsclient\SessionHandler(\App::$http));
270        $sessionHandler->open('/' . $realmData['realm'] . '/', $realmData['clientId']);
271        $sessionHandler->write($sessionKey, serialize($token), ['oidc' => true]);
272        $sessionHandler->close();
273    }
274
275    private function writeDeleteSession(): void
276    {
277        \App::$log->info('Deleting session', [
278            'event' => 'oauth_delete_session',
279            'timestamp' => date('c')
280        ]);
281
282        $sessionKey = $this->getSessionKey();
283        if ($sessionKey === null) {
284            return;
285        }
286        $realmData = $this->provider->getBasicOptionsFromJsonFile();
287        $sessionHandler = (new \BO\Zmsclient\SessionHandler(\App::$http));
288        $sessionHandler->open('/' . $realmData['realm'] . '/', $realmData['clientId']);
289        $sessionHandler->destroy($sessionKey);
290    }
291
292    private function readTokenDataFromSession(): mixed
293    {
294        \App::$log->info('Reading token from session', [
295            'event' => 'oauth_read_token',
296            'timestamp' => date('c')
297        ]);
298
299        $sessionKey = $this->getSessionKey();
300        if ($sessionKey === null) {
301            return [];
302        }
303        $realmData = $this->provider->getBasicOptionsFromJsonFile();
304        $sessionHandler = (new \BO\Zmsclient\SessionHandler(\App::$http));
305        $sessionHandler->open('/' . $realmData['realm'] . '/', $realmData['clientId']);
306        return unserialize($sessionHandler->read($sessionKey, ['oidc' => true]));
307    }
308
309    private function getSessionKey(): ?string
310    {
311        $key = \BO\Zmsclient\Auth::getKey();
312        return ($key !== null && $key !== '') ? $key : null;
313    }
314}