Passed
Push — master ( fdd409...5e2aee )
by Yaroslav
02:55
created

SSOClient::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 17
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 12
c 1
b 0
f 0
nc 1
nop 5
dl 0
loc 17
ccs 13
cts 13
cp 1
crap 1
rs 9.8666
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 TypeError;
18
19
class SSOClient
20
{
21
22
    /**
23
     * Object to manipulate oAuth state.
24
     *
25
     * @var StateManager
26
     */
27
    protected StateManager $stateManager;
28
29
    /**
30
     * Object to manipulate oAuth routes.
31
     *
32
     * @var RoutesManager
33
     */
34
    protected RoutesManager $routesManager;
35
36
    /**
37
     * The HTTP Client instance.
38
     *
39
     * @var PendingRequest|null
40
     */
41
    protected ?PendingRequest $httpClient = null;
42
43
    /**
44
     * The client ID.
45
     *
46
     * @var string
47
     */
48
    protected string $clientId;
49
50
    /**
51
     * The client secret.
52
     *
53
     * @var string
54
     */
55
    protected string $clientSecret;
56
57
    /**
58
     * The redirect URL.
59
     *
60
     * @var string
61
     */
62
    protected string $redirectUrl;
63
64
    /**
65
     * The custom parameters to be sent with the request.
66
     *
67
     * @var array
68
     */
69
    protected array $parameters = [];
70
71
    /**
72
     * The scopes what being requested.
73
     *
74
     * @var array
75
     */
76
    protected array $scopes = [];
77
78
    /**
79
     * The custom Guzzle configuration options.
80
     *
81
     * @var array
82
     */
83
    protected array $guzzle = [];
84
85
    /**
86
     * FMC SSO user.
87
     *
88
     * @var SSOUser|null
89
     */
90
    protected ?SSOUser $ssoUser = null;
91
92
    /**
93
     * SSOClient constructor.
94
     *
95
     * @param string $clientId
96
     * @param string $clientSecret
97
     * @param string $redirectUrl
98
     * @param array $ssoConfig
99
     * @param array $guzzle
100
     */
101 20
    public function __construct(string $clientId, string $clientSecret, string $redirectUrl, array $ssoConfig = [], array $guzzle = [])
102
    {
103 20
        $this->stateManager  = new StateManager((bool) $ssoConfig['useState']);
104 20
        $this->routesManager = new RoutesManager(
105 20
            $ssoConfig['domain'],
106 20
            $ssoConfig['authorizePath'],
107 20
            $ssoConfig['tokenPath'],
108 20
            $ssoConfig['userPath'],
109 20
            $ssoConfig['scopeSeparator'],
110 20
            (bool) $ssoConfig['ssl']
111
        );
112
113 20
        $this->clientId     = $clientId;
114 20
        $this->redirectUrl  = $redirectUrl;
115 20
        $this->clientSecret = $clientSecret;
116
117 20
        $this->guzzle = $guzzle;
118 20
    }
119
120
    /**
121
     * Redirect to SSO authorize url.
122
     *
123
     * @return RedirectResponse
124
     */
125 3
    public function redirect(): RedirectResponse
126
    {
127 3
        return new RedirectResponse($this->routesManager->getAuthUrl($this->getCodeFields()));
128
    }
129
130
    /**
131
     * Get the GET parameters for the code request.
132
     *
133
     * @param string|null $state
134
     *
135
     * @return array
136
     */
137 3
    protected function getCodeFields(?string $state = null): array
0 ignored issues
show
Unused Code introduced by
The parameter $state is not used and could be removed. ( Ignorable by Annotation )

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

137
    protected function getCodeFields(/** @scrutinizer ignore-unused */ ?string $state = null): array

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
138
    {
139 3
        $fields = [
140 3
            'client_id'     => $this->clientId,
141 3
            'redirect_uri'  => $this->redirectUrl,
142 3
            'scope'         => $this->routesManager->prepareScopes($this->getScopes()),
143 3
            'response_type' => 'code',
144 3
            'state'         => $this->stateManager->makeState(),
145
        ];
146
147 3
        return array_filter(array_merge($fields, $this->parameters));
148
    }
149
150
    /**
151
     * Set the scopes of the requested access.
152
     *
153
     * @param array|string $scopes
154
     *
155
     * @return static
156
     */
157 5
    public function setScopes(array|string $scopes): SSOClient
158
    {
159 5
        $this->scopes = array_unique((array) $scopes);
160
161 5
        return $this;
162
    }
163
164
    /**
165
     * Get the current scopes.
166
     *
167
     * @return array
168
     */
169 9
    public function getScopes(): array
170
    {
171 9
        return $this->scopes;
172
    }
173
174
    /**
175
     * Set the custom parameters of the request.
176
     *
177
     * @param array $parameters
178
     *
179
     * @return static
180
     */
181 1
    public function with(array $parameters): SSOClient
182
    {
183 1
        $this->parameters = $parameters;
184
185 1
        return $this;
186
    }
187
188
    /**
189
     * OAuth part 2, get state and find info about user.
190
     *
191
     * @return SSOUser
192
     *
193
     * @throws InvalidUserResponseException|InvalidTokenResponseException
194
     */
195 9
    public function ssoUser(): SSOUser
196
    {
197 9
        if ($this->ssoUser) {
198 1
            return $this->ssoUser;
199
        }
200
201 9
        if ($this->hasInvalidState()) {
202
            throw new InvalidStateException('FMC SSO response contain not valid "state".');
203
        }
204
205 9
        if (!is_null($errorType = $this->getCodeError())) {
206 2
            throw new CodeErrorException($errorType);
207
        }
208
209 7
        $token = $this->getAccessTokenResponse($this->getCode());
210
211 3
        return $this->getSSOUserByToken($token);
212
    }
213
214
    /**
215
     * @param Token $token
216
     *
217
     * @return SSOUser
218
     * @throws InvalidUserResponseException
219
     */
220 3
    public function getSSOUserByToken(Token $token): SSOUser
221
    {
222 3
        $this->ssoUser = $this->mapUserToObject($this->getUserByToken($token));
223
224 2
        return $this->ssoUser->setToken($token);
225
    }
226
227
    /**
228
     * Get the code from the request.
229
     *
230
     * @return string
231
     */
232 7
    protected function getCode(): string
233
    {
234 7
        $code = request()->input('code', '');
235 7
        if (empty($code) || !is_string($code)) {
236 1
            throw new InvalidResponseUrlException('Code parameter is empty or not valid');
237
        }
238
239 6
        return $code;
240
    }
241
242
    /**
243
     * Get the error from the request.
244
     *
245
     * @return string|null
246
     */
247 9
    protected function getCodeError(): ?string
248
    {
249 9
        if (request()->has('error')) {
250 2
            return (string) request()->input('error', '');
251
        }
252
253 7
        return null;
254
    }
255
256
    /**
257
     * Determine if the current request / session has a mismatching "state".
258
     *
259
     * @return bool
260
     */
261 9
    protected function hasInvalidState(): bool
262
    {
263 9
        if (!$this->stateManager->needValidateState()) {
264 9
            return false;
265
        }
266
        $state = $this->stateManager->pullState();
267
268
        return !(strlen($state) > 0 && request()->input('state') === $state);
0 ignored issues
show
Bug introduced by
It seems like $state can also be of type null; however, parameter $string of strlen() 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

268
        return !(strlen(/** @scrutinizer ignore-type */ $state) > 0 && request()->input('state') === $state);
Loading history...
269
    }
270
271
    /**
272
     * Get the access token response for the given code.
273
     *
274
     * @param string $code
275
     *
276
     * @return Token
277
     * @throws InvalidTokenResponseException
278
     */
279 6
    public function getAccessTokenResponse(string $code): Token
280
    {
281 6
        $response = $this->getHttpClient()
282 6
                         ->post($this->routesManager->getTokenUrl(), $this->getTokenFields($code));
283
284 6
        return $this->getTokenFromResponse($response);
285
    }
286
287
    /**
288
     * Refresh token.
289
     *
290
     * @param Token $token
291
     *
292
     * @return Token|null
293
     * @throws InvalidTokenResponseException
294
     */
295 3
    public function refreshToken(Token $token): ?Token
296
    {
297 3
        if (!$token->valid() || !$token->getRefreshToken()) {
298 2
            return null;
299
        }
300 1
        $response = $this->getHttpClient()
301 1
                         ->post($this->routesManager->getTokenUrl(), $this->getRefreshTokenFields($token->getRefreshToken()));
302
303 1
        return $this->getTokenFromResponse($response);
304
    }
305
306
    /**
307
     * Get an instance of the Guzzle HTTP client.
308
     *
309
     * @return PendingRequest
310
     */
311 7
    protected function getHttpClient(): PendingRequest
312
    {
313 7
        if (is_null($this->httpClient)) {
314 7
            $this->httpClient = Http::withOptions($this->guzzle)
315 7
                                    ->withHeaders([ 'Accept' => 'application/json' ]);
316
        }
317
318 7
        return $this->httpClient;
319
    }
320
321
    /**
322
     * Get the POST fields for the token request.
323
     *
324
     * @param string $code
325
     *
326
     * @return array
327
     */
328 6
    protected function getTokenFields(string $code): array
329
    {
330
        return [
331 6
            'grant_type'    => 'authorization_code',
332 6
            'client_id'     => $this->clientId,
333 6
            'client_secret' => $this->clientSecret,
334 6
            'code'          => $code,
335 6
            'redirect_uri'  => $this->redirectUrl,
336
        ];
337
    }
338
339
    /**
340
     * Get the POST fields for the refresh token request.
341
     *
342
     * @param string $refreshToken
343
     *
344
     * @return array
345
     */
346 1
    protected function getRefreshTokenFields(string $refreshToken): array
347
    {
348
        return [
349 1
            'grant_type'    => 'refresh_token',
350 1
            'client_id'     => $this->clientId,
351 1
            'client_secret' => $this->clientSecret,
352 1
            'scope'         => $this->routesManager->prepareScopes($this->getScopes()),
353 1
            'refresh_token' => $refreshToken,
354
        ];
355
    }
356
357
    /**
358
     * Get the raw user for the given access token.
359
     *
360
     * @param Token $token
361
     *
362
     * @return array
363
     * @throws InvalidUserResponseException
364
     */
365 3
    protected function getUserByToken(Token $token): array
366
    {
367 3
        $response = $this->getHttpClient()
368 3
                         ->withHeaders([ 'Authorization' => $token->getAuthorizationHeader() ])
369 3
                         ->get($this->routesManager->getUserUrl());
370
371 3
        $user = $response->json('data');
372
373 3
        if (!is_array($user) || empty($user)) {
374 1
            throw new InvalidUserResponseException('User response format not valid.');
375
        }
376
377
378 2
        return $user;
379
    }
380
381
    /**
382
     * Map the raw user array to a Socialite User instance.
383
     *
384
     * @param array $user
385
     *
386
     * @return SSOUser
387
     */
388 2
    protected function mapUserToObject(array $user): SSOUser
389
    {
390 2
        return ( new SSOUser() )->setRaw($user)->map([
391 2
            'id'         => $user['id'],
392 2
            'title'      => Arr::get($user, 'title'),
393 2
            'first_name' => Arr::get($user, 'first_name'),
394 2
            'last_name'  => Arr::get($user, 'last_name'),
395 2
            'email'      => Arr::get($user, 'email'),
396
        ]);
397
    }
398
399
    /**
400
     * @param Response $response
401
     *
402
     * @return Token|null
403
     * @throws InvalidTokenResponseException
404
     */
405 7
    protected function getTokenFromResponse(Response $response): ?Token
406
    {
407 7
        if (!$response->successful()) {
408 1
            throw new InvalidTokenResponseException($response->json('message', $response->json('hint', 'Token response not valid.')));
409
        }
410
411
        try {
412 6
            $token = new Token(
413 6
                $response->json('token_type'),
414 6
                $response->json('expires_in'),
415 6
                $response->json('access_token'),
416 6
                $response->json('refresh_token'),
417
            );
418 1
        } catch (TypeError $e) {
419 1
            throw new InvalidTokenResponseException(message: 'Token response has not valid params', previous: $e);
420
        }
421
422 5
        if (!$token->valid()) {
423 1
            throw new InvalidTokenResponseException('Token not valid or expired');
424
        }
425
426 4
        return $token;
427
    }
428
}
429