Passed
Push — master ( b3b1e0...325ef0 )
by Yaroslav
03:17
created

SSOClient   A

Complexity

Total Complexity 37

Size/Duplication

Total Lines 417
Duplicated Lines 0 %

Test Coverage

Coverage 97.44%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 110
c 2
b 0
f 0
dl 0
loc 417
ccs 114
cts 117
cp 0.9744
rs 9.44
wmc 37

19 Methods

Rating   Name   Duplication   Size   Complexity  
A getAccessTokenResponse() 0 6 1
A with() 0 5 1
A getScopes() 0 3 1
A getCodeError() 0 7 2
A getCode() 0 8 3
A ssoUser() 0 17 4
A setScopes() 0 5 1
A getSSOUserByToken() 0 5 1
A getCodeFields() 0 11 1
A __construct() 0 18 1
A hasInvalidState() 0 8 4
A redirect() 0 3 1
A getUserByToken() 0 14 3
A getTokenFromResponse() 0 22 4
A mapUserToObject() 0 8 1
A getTokenFields() 0 8 1
A getRefreshTokenFields() 0 14 2
A refreshToken() 0 9 3
A getHttpClient() 0 8 2
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 $scopes
99
     * @param array $ssoConfig
100
     * @param array $guzzle
101
     */
102 22
    public function __construct(string $clientId, string $clientSecret, string $redirectUrl, array $scopes = [], array $ssoConfig = [], array $guzzle = [])
103
    {
104 22
        $this->stateManager  = new StateManager((bool) $ssoConfig['useState']);
105 22
        $this->routesManager = new RoutesManager(
106 22
            $ssoConfig['domain'],
107 22
            $ssoConfig['authorizePath'],
108 22
            $ssoConfig['tokenPath'],
109 22
            $ssoConfig['userPath'],
110 22
            $ssoConfig['scopeSeparator'],
111 22
            (bool) $ssoConfig['ssl']
112
        );
113
114 22
        $this->clientId     = $clientId;
115 22
        $this->redirectUrl  = $redirectUrl;
116 22
        $this->clientSecret = $clientSecret;
117 22
        $this->scopes       = $scopes;
118
119 22
        $this->guzzle = $guzzle;
120 22
    }
121
122
    /**
123
     * Redirect to SSO authorize url.
124
     *
125
     * @return RedirectResponse
126
     */
127 4
    public function redirect(): RedirectResponse
128
    {
129 4
        return new RedirectResponse($this->routesManager->getAuthUrl($this->getCodeFields()));
130
    }
131
132
    /**
133
     * Get the GET parameters for the code request.
134
     *
135
     * @return array
136
     */
137 4
    protected function getCodeFields(): array
138
    {
139 4
        $fields = [
140 4
            'client_id'     => $this->clientId,
141 4
            'redirect_uri'  => $this->redirectUrl,
142 4
            'scope'         => $this->routesManager->prepareScopes($this->getScopes()),
143 4
            'response_type' => 'code',
144 4
            'state'         => $this->stateManager->makeState(),
145
        ];
146
147 4
        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 is_string($state) && !(strlen($state) > 0 && request()->input('state') === $state);
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
     * @param array|null $scopes The requested scope must not include additional scopes that were not issued in
292
     * the original access token. If omitted, the service should issue an access token with the same scope
293
     * as was previously issued.
294
     *
295
     * @return Token|null
296
     * @throws InvalidTokenResponseException
297
     */
298 4
    public function refreshToken(Token $token, ?array $scopes = null): ?Token
299
    {
300 4
        if (!$token->valid() || !$token->getRefreshToken()) {
301 2
            return null;
302
        }
303 2
        $response = $this->getHttpClient()
304 2
                         ->post($this->routesManager->getTokenUrl(), $this->getRefreshTokenFields($token->getRefreshToken(), $scopes));
305
306 2
        return $this->getTokenFromResponse($response);
307
    }
308
309
    /**
310
     * Get an instance of the Guzzle HTTP client.
311
     *
312
     * @return PendingRequest
313
     */
314 8
    protected function getHttpClient(): PendingRequest
315
    {
316 8
        if (is_null($this->httpClient)) {
317 8
            $this->httpClient = Http::withOptions($this->guzzle)
318 8
                                    ->withHeaders([ 'Accept' => 'application/json' ]);
319
        }
320
321 8
        return $this->httpClient;
322
    }
323
324
    /**
325
     * Get the POST fields for the token request.
326
     *
327
     * @param string $code
328
     *
329
     * @return array
330
     */
331 6
    protected function getTokenFields(string $code): array
332
    {
333
        return [
334 6
            'grant_type'    => 'authorization_code',
335 6
            'client_id'     => $this->clientId,
336 6
            'client_secret' => $this->clientSecret,
337 6
            'code'          => $code,
338 6
            'redirect_uri'  => $this->redirectUrl,
339
        ];
340
    }
341
342
    /**
343
     * Get the POST fields for the refresh token request.
344
     *
345
     * @param string $refreshToken
346
     * @param array|null $scopes
347
     *
348
     * @return array
349
     */
350 2
    protected function getRefreshTokenFields(string $refreshToken, ?array $scopes = null): array
351
    {
352 2
        $query = [
353 2
            'grant_type'    => 'refresh_token',
354 2
            'client_id'     => $this->clientId,
355 2
            'client_secret' => $this->clientSecret,
356 2
            'refresh_token' => $refreshToken,
357
        ];
358
359 2
        if (!empty($scopes)) {
360 1
            $query['scope'] = $this->routesManager->prepareScopes($scopes);
361
        }
362
363 2
        return $query;
364
    }
365
366
    /**
367
     * Get the raw user for the given access token.
368
     *
369
     * @param Token $token
370
     *
371
     * @return array
372
     * @throws InvalidUserResponseException
373
     */
374 3
    protected function getUserByToken(Token $token): array
375
    {
376 3
        $response = $this->getHttpClient()
377 3
                         ->withHeaders([ 'Authorization' => $token->getAuthorizationHeader() ])
378 3
                         ->get($this->routesManager->getUserUrl());
379
380 3
        $user = $response->json('data');
381
382 3
        if (!is_array($user) || empty($user)) {
383 1
            throw new InvalidUserResponseException('User response format not valid.');
384
        }
385
386
387 2
        return $user;
388
    }
389
390
    /**
391
     * Map the raw user array to a Socialite User instance.
392
     *
393
     * @param array $user
394
     *
395
     * @return SSOUser
396
     */
397 2
    protected function mapUserToObject(array $user): SSOUser
398
    {
399 2
        return ( new SSOUser() )->setRaw($user)->map([
400 2
            'id'         => $user['id'],
401 2
            'title'      => Arr::get($user, 'title'),
402 2
            'first_name' => Arr::get($user, 'first_name'),
403 2
            'last_name'  => Arr::get($user, 'last_name'),
404 2
            'email'      => Arr::get($user, 'email'),
405
        ]);
406
    }
407
408
    /**
409
     * @param Response $response
410
     *
411
     * @return Token|null
412
     * @throws InvalidTokenResponseException
413
     */
414 8
    protected function getTokenFromResponse(Response $response): ?Token
415
    {
416 8
        if (!$response->successful()) {
417 1
            throw new InvalidTokenResponseException($response->json('message', $response->json('hint', 'Token response not valid.')));
418
        }
419
420
        try {
421 7
            $token = new Token(
422 7
                $response->json('token_type'),
423 7
                $response->json('expires_in'),
424 7
                $response->json('access_token'),
425 7
                $response->json('refresh_token'),
426
            );
427 1
        } catch (TypeError $e) {
428 1
            throw new InvalidTokenResponseException(message: 'Token response has not valid params', previous: $e);
429
        }
430
431 6
        if (!$token->valid()) {
432 1
            throw new InvalidTokenResponseException('Token not valid or expired');
433
        }
434
435 5
        return $token;
436
    }
437
}
438