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