SSOClient::hasInvalidState()   A
last analyzed

Complexity

Conditions 4
Paths 4

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 5.024

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 4
c 1
b 0
f 0
nc 4
nop 0
dl 0
loc 8
ccs 3
cts 5
cp 0.6
crap 5.024
rs 10
1
<?php
2
3
namespace FMCSSOClient;
4
5
use FMCSSOClient\Exceptions\CodeErrorException;
6
use FMCSSOClient\Exceptions\InvalidResponseUrlException;
7
use FMCSSOClient\Exceptions\InvalidStateException;
8
use FMCSSOClient\Exceptions\InvalidTokenResponseException;
9
use FMCSSOClient\Exceptions\InvalidUserResponseException;
10
use FMCSSOClient\OAuth\RoutesManager;
11
use FMCSSOClient\OAuth\StateManager;
12
use Illuminate\Http\Client\PendingRequest;
13
use Illuminate\Http\Client\Response;
14
use Illuminate\Http\RedirectResponse;
15
use Illuminate\Support\Arr;
16
use Illuminate\Support\Facades\Http;
17
use Illuminate\Support\Str;
18
use TypeError;
19
20
class SSOClient
21
{
22
23
    /**
24
     * Object to manipulate oAuth state.
25
     *
26
     * @var StateManager
27
     */
28
    protected StateManager $stateManager;
29
30
    /**
31
     * Object to manipulate oAuth routes.
32
     *
33
     * @var RoutesManager
34
     */
35
    protected RoutesManager $routesManager;
36
37
    /**
38
     * The HTTP Client instance.
39
     *
40
     * @var PendingRequest|null
41
     */
42
    protected ?PendingRequest $httpClient = null;
43
44
    /**
45
     * The client ID.
46
     *
47
     * @var string
48
     */
49
    protected string $clientId;
50
51
    /**
52
     * The client secret.
53
     *
54
     * @var string
55
     */
56
    protected string $clientSecret;
57
58
    /**
59
     * The redirect URL.
60
     *
61
     * @var string
62
     */
63
    protected string $redirectUrl;
64
65
    /**
66
     * The custom parameters to be sent with the request.
67
     *
68
     * @var array
69
     */
70
    protected array $parameters = [];
71
72
    /**
73
     * The scopes what being requested.
74
     *
75
     * @var array
76
     */
77
    protected array $scopes = [];
78
79
    /**
80
     * The custom Guzzle configuration options.
81
     *
82
     * @var array
83
     */
84
    protected array $guzzle = [];
85
86
    /**
87
     * FMC SSO user.
88
     *
89
     * @var SSOUser|null
90
     */
91
    protected ?SSOUser $ssoUser = null;
92
93
    /**
94
     * SSOClient constructor.
95
     *
96
     * @param string $clientId
97
     * @param string $clientSecret
98
     * @param string $redirectUrl
99
     * @param array $scopes
100
     * @param array $ssoConfig
101
     * @param array $guzzle
102
     */
103 23
    public function __construct(string $clientId, string $clientSecret, string $redirectUrl, array $scopes = [], array $ssoConfig = [], array $guzzle = [])
104
    {
105 23
        $this->stateManager  = new StateManager((bool) $ssoConfig['useState']);
106 23
        $this->routesManager = new RoutesManager(
107 23
            $ssoConfig['domain'],
108 23
            $ssoConfig['authorizePath'],
109 23
            $ssoConfig['tokenPath'],
110 23
            $ssoConfig['userPath'],
111 23
            $ssoConfig['scopeSeparator'],
112 23
            (bool) $ssoConfig['ssl']
113
        );
114
115 23
        $this->clientId     = $clientId;
116 23
        $this->redirectUrl  = $redirectUrl;
117 23
        $this->clientSecret = $clientSecret;
118 23
        $this->scopes       = $scopes;
119
120 23
        $this->guzzle = $guzzle;
121
    }
122
123
    /**
124
     * Redirect to SSO authorize url.
125
     *
126
     * @return RedirectResponse
127
     */
128 4
    public function redirect(): RedirectResponse
129
    {
130 4
        return new RedirectResponse($this->routesManager->getAuthUrl($this->getCodeFields()));
131
    }
132
133
    /**
134
     * Get the GET parameters for the code request.
135
     *
136
     * @return array
137
     */
138 4
    protected function getCodeFields(): array
139
    {
140 4
        $fields = [
141 4
            'client_id'     => $this->clientId,
142 4
            'redirect_uri'  => $this->redirectUrl,
143 4
            'scope'         => $this->routesManager->prepareScopes($this->getScopes()),
144
            'response_type' => 'code',
145 4
            'state'         => $this->stateManager->makeState(),
146
        ];
147
148 4
        return array_filter(array_merge($fields, $this->parameters));
149
    }
150
151
    /**
152
     * Set the scopes of the requested access.
153
     *
154
     * @param array|string $scopes
155
     *
156
     * @return static
157
     */
158 5
    public function setScopes(array|string $scopes): SSOClient
159
    {
160 5
        $this->scopes = array_unique((array) $scopes);
161
162 5
        return $this;
163
    }
164
165
    /**
166
     * Get the current scopes.
167
     *
168
     * @return array
169
     */
170 9
    public function getScopes(): array
171
    {
172 9
        return $this->scopes;
173
    }
174
175
    /**
176
     * Set the custom parameters of the request.
177
     *
178
     * @param array $parameters
179
     *
180
     * @return static
181
     */
182 1
    public function with(array $parameters): SSOClient
183
    {
184 1
        $this->parameters = $parameters;
185
186 1
        return $this;
187
    }
188
189
    /**
190
     * OAuth part 2, get state and find info about user.
191
     *
192
     * @return SSOUser
193
     *
194
     * @throws InvalidUserResponseException|InvalidTokenResponseException
195
     */
196 10
    public function ssoUser(): SSOUser
197
    {
198 10
        if ($this->ssoUser) {
199 1
            return $this->ssoUser;
200
        }
201
202 10
        if ($this->hasInvalidState()) {
203
            throw new InvalidStateException('FMC SSO response contain not valid "state".');
204
        }
205
206 10
        if (!is_null($errorType = $this->getCodeError())) {
207 2
            throw new CodeErrorException($errorType);
208
        }
209
210 8
        $token = $this->getAccessTokenResponse($this->getCode());
211
212 4
        return $this->getSSOUserByToken($token);
213
    }
214
215
    /**
216
     * @param Token $token
217
     *
218
     * @return SSOUser
219
     * @throws InvalidUserResponseException
220
     */
221 4
    public function getSSOUserByToken(Token $token): SSOUser
222
    {
223 4
        $this->ssoUser = $this->mapUserToObject($this->getUserByToken($token));
224
225 2
        return $this->ssoUser->setToken($token);
226
    }
227
228
    /**
229
     * Get the code from the request.
230
     *
231
     * @return string
232
     */
233 8
    protected function getCode(): string
234
    {
235 8
        $code = request()->input('code', '');
236 8
        if (empty($code) || !is_string($code)) {
237 1
            throw new InvalidResponseUrlException('Code parameter is empty or not valid');
238
        }
239
240 7
        return $code;
241
    }
242
243
    /**
244
     * Get the error from the request.
245
     *
246
     * @return string|null
247
     */
248 10
    protected function getCodeError(): ?string
249
    {
250 10
        if (request()->has('error')) {
251 2
            return (string) request()->input('error', '');
252
        }
253
254 8
        return null;
255
    }
256
257
    /**
258
     * Determine if the current request / session has a mismatching "state".
259
     *
260
     * @return bool
261
     */
262 10
    protected function hasInvalidState(): bool
263
    {
264 10
        if (!$this->stateManager->needValidateState()) {
265 10
            return false;
266
        }
267
        $state = $this->stateManager->pullState();
268
269
        return is_string($state) && !(strlen($state) > 0 && request()->input('state') === $state);
270
    }
271
272
    /**
273
     * Get the access token response for the given code.
274
     *
275
     * @param string $code
276
     *
277
     * @return Token
278
     * @throws InvalidTokenResponseException
279
     */
280 7
    public function getAccessTokenResponse(string $code): Token
281
    {
282 7
        $response = $this->getHttpClient()
283 7
                         ->post($this->routesManager->getTokenUrl(), $this->getTokenFields($code));
284
285 7
        return $this->getTokenFromResponse($response);
286
    }
287
288
    /**
289
     * Refresh token.
290
     *
291
     * @param Token $token
292
     * @param array|null $scopes The requested scope must not include additional scopes that were not issued in
293
     * the original access token. If omitted, the service should issue an access token with the same scope
294
     * as was previously issued.
295
     *
296
     * @return Token|null
297
     * @throws InvalidTokenResponseException
298
     */
299 4
    public function refreshToken(Token $token, ?array $scopes = null): ?Token
300
    {
301 4
        if (!$token->valid() || !$token->getRefreshToken()) {
302 2
            return null;
303
        }
304 2
        $response = $this->getHttpClient()
305 2
                         ->post($this->routesManager->getTokenUrl(), $this->getRefreshTokenFields($token->getRefreshToken(), $scopes));
306
307 2
        return $this->getTokenFromResponse($response);
308
    }
309
310
    /**
311
     * Get an instance of the Guzzle HTTP client.
312
     *
313
     * @return PendingRequest
314
     */
315 9
    protected function getHttpClient(): PendingRequest
316
    {
317 9
        if (is_null($this->httpClient)) {
318 9
            $this->httpClient = Http::withOptions($this->guzzle)
319 9
                                    ->withHeaders([ 'Accept' => 'application/json' ]);
320
        }
321
322 9
        return $this->httpClient;
323
    }
324
325
    /**
326
     * Get the POST fields for the token request.
327
     *
328
     * @param string $code
329
     *
330
     * @return array
331
     */
332 7
    protected function getTokenFields(string $code): array
333
    {
334
        return [
335 7
            'grant_type'    => 'authorization_code',
336 7
            'client_id'     => $this->clientId,
337 7
            'client_secret' => $this->clientSecret,
338
            'code'          => $code,
339 7
            'redirect_uri'  => $this->redirectUrl,
340
        ];
341
    }
342
343
    /**
344
     * Get the POST fields for the refresh token request.
345
     *
346
     * @param string $refreshToken
347
     * @param array|null $scopes
348
     *
349
     * @return array
350
     */
351 2
    protected function getRefreshTokenFields(string $refreshToken, ?array $scopes = null): array
352
    {
353 2
        $query = [
354
            'grant_type'    => 'refresh_token',
355 2
            'client_id'     => $this->clientId,
356 2
            'client_secret' => $this->clientSecret,
357
            'refresh_token' => $refreshToken,
358
        ];
359
360 2
        if (!empty($scopes)) {
361 1
            $query['scope'] = $this->routesManager->prepareScopes($scopes);
362
        }
363
364 2
        return $query;
365
    }
366
367
    /**
368
     * Get the raw user for the given access token.
369
     *
370
     * @param Token $token
371
     *
372
     * @return array
373
     * @throws InvalidUserResponseException
374
     */
375 4
    protected function getUserByToken(Token $token): array
376
    {
377 4
        $response = $this->getHttpClient()
378 4
                         ->withHeaders([ 'Authorization' => $token->getAuthorizationHeader() ])
379 4
                         ->get($this->routesManager->getUserUrl());
380
381 4
        if (!$response->successful()) {
382 1
            throw new InvalidUserResponseException("User response status error. Status: {$response->status()}. Body: " . Str::limit(print_r($response->body(), true), 100));
0 ignored issues
show
Bug introduced by
It seems like print_r($response->body(), true) can also be of type true; however, parameter $value of Illuminate\Support\Str::limit() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

382
            throw new InvalidUserResponseException("User response status error. Status: {$response->status()}. Body: " . Str::limit(/** @scrutinizer ignore-type */ print_r($response->body(), true), 100));
Loading history...
383
        }
384
385 3
        $user = $response->json('data');
386
387 3
        if (!is_array($user) || empty($user)) {
388 1
            throw new InvalidUserResponseException('User response format not valid: ' . Str::limit(print_r($response->body(), true), 100));
389
        }
390
391
392 2
        return $user;
393
    }
394
395
    /**
396
     * Map the raw user array to a Socialite User instance.
397
     *
398
     * @param array $user
399
     *
400
     * @return SSOUser
401
     */
402 2
    protected function mapUserToObject(array $user): SSOUser
403
    {
404 2
        return ( new SSOUser() )->setRaw($user)->map([
405 2
            'id'         => $user['id'],
406 2
            'title'      => Arr::get($user, 'title'),
407 2
            'first_name' => Arr::get($user, 'first_name'),
408 2
            'last_name'  => Arr::get($user, 'last_name'),
409 2
            'email'      => Arr::get($user, 'email'),
410
        ]);
411
    }
412
413
    /**
414
     * @param Response $response
415
     *
416
     * @return Token|null
417
     * @throws InvalidTokenResponseException
418
     */
419 9
    protected function getTokenFromResponse(Response $response): ?Token
420
    {
421 9
        if (!$response->successful()) {
422 1
            throw new InvalidTokenResponseException($response->json('message', $response->json('hint', 'Token response not valid.')));
423
        }
424
425
        try {
426 8
            $token = new Token(
427 8
                $response->json('token_type'),
428 8
                $response->json('expires_in'),
429 8
                $response->json('access_token'),
430 8
                $response->json('refresh_token'),
431
            );
432 1
        } catch (TypeError $e) {
433 1
            throw new InvalidTokenResponseException(message: 'Token response has not valid params', previous: $e);
434
        }
435
436 7
        if (!$token->valid()) {
437 1
            throw new InvalidTokenResponseException('Token not valid or expired');
438
        }
439
440 6
        return $token;
441
    }
442
}
443