OAuth2   A
last analyzed

Complexity

Total Complexity 34

Size/Duplication

Total Lines 435
Duplicated Lines 0 %

Test Coverage

Coverage 11.5%

Importance

Changes 0
Metric Value
eloc 149
dl 0
loc 435
ccs 23
cts 200
cp 0.115
rs 9.68
c 0
b 0
f 0
wmc 34

20 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 9 1
A generateAuthState() 0 7 2
A createToken() 0 5 1
A getClientSecret() 0 3 1
A setTokenUrl() 0 3 1
A setClientSecret() 0 3 1
A getTokenUrl() 0 3 1
A setClientId() 0 3 1
A defaultReturnUrl() 0 6 1
A getClientId() 0 3 1
A withValidateAuthState() 0 5 1
A withoutValidateAuthState() 0 5 1
B authenticateUserJwt() 0 73 6
A applyClientCredentialsToRequest() 0 7 1
A buildAuthUrl() 0 21 3
A authenticateClient() 0 28 2
A applyAccessTokenToRequest() 0 6 1
A authenticateUser() 0 30 2
A refreshAccessToken() 0 22 1
A fetchAccessToken() 0 36 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Yii\AuthClient;
6
7
use InvalidArgumentException;
8
use JsonException;
9
use Psr\Http\Message\RequestFactoryInterface;
10
use Psr\Http\Message\RequestInterface;
11
use Psr\Http\Message\ServerRequestInterface;
12
use Yiisoft\Factory\Factory;
13
use Yiisoft\Json\Json;
14
use Yiisoft\Session\SessionInterface;
15
use Yiisoft\Yii\AuthClient\Signature\Signature;
16
use Yiisoft\Yii\AuthClient\StateStorage\StateStorageInterface;
17
18
/**
19
 * OAuth2 serves as a client for the OAuth 2 flow.
20
 *
21
 * In oder to acquire access token perform following sequence:
22
 *
23
 * ```php
24
 * use Yiisoft\Yii\AuthClient\OAuth2;
25
 *
26
 * // assuming class MyAuthClient extends OAuth2
27
 * $oauthClient = new MyAuthClient();
28
 * $url = $oauthClient->buildAuthUrl(); // Build authorization URL
29
 * Yii::getApp()->getResponse()->redirect($url); // Redirect to authorization URL.
30
 * // After user returns at our site:
31
 * $code = Yii::getApp()->getRequest()->get('code');
32
 * $accessToken = $oauthClient->fetchAccessToken($code); // Get access token
33
 * ```
34
 *
35
 * @see https://oauth.net/2/
36
 * @see https://tools.ietf.org/html/rfc6749
37
 */
38
abstract class OAuth2 extends OAuth
39
{
40
    /**
41
     * @var string OAuth client ID.
42
     */
43
    protected string $clientId;
44
    /**
45
     * @var string OAuth client secret.
46
     */
47
    protected string $clientSecret;
48
    /**
49
     * @var string token request URL endpoint.
50
     */
51
    protected string $tokenUrl;
52
    /**
53
     * @var bool whether to use and validate auth 'state' parameter in authentication flow.
54
     * If enabled - the opaque value will be generated and applied to auth URL to maintain
55
     * state between the request and callback. The authorization server includes this value,
56
     * when redirecting the user-agent back to the client.
57
     * The option is used for preventing cross-site request forgery.
58
     */
59
    protected bool $validateAuthState = true;
60
    private SessionInterface $session;
61
62 1
    public function __construct(
63
        \Psr\Http\Client\ClientInterface $httpClient,
64
        RequestFactoryInterface $requestFactory,
65
        StateStorageInterface $stateStorage,
66
        SessionInterface $session,
67
        Factory $factory
68
    ) {
69 1
        parent::__construct($httpClient, $requestFactory, $stateStorage, $factory);
70 1
        $this->session = $session;
71
    }
72
73
    /**
74
     * Composes user authorization URL.
75
     *
76
     * @param ServerRequestInterface $incomingRequest
77
     * @param array $params additional auth GET params.
78
     *
79
     * @return string authorization URL.
80
     */
81 1
    public function buildAuthUrl(
82
        ServerRequestInterface $incomingRequest,
83
        array $params = []
84
    ): string {
85 1
        $defaultParams = [
86 1
            'client_id' => $this->clientId,
87 1
            'response_type' => 'code',
88 1
            'redirect_uri' => $this->getReturnUrl($incomingRequest),
89 1
            'xoauth_displayname' => $incomingRequest->getAttribute(AuthAction::AUTH_NAME),
90 1
        ];
91 1
        if (!empty($this->getScope())) {
92
            $defaultParams['scope'] = $this->getScope();
93
        }
94
95 1
        if ($this->validateAuthState) {
96 1
            $authState = $this->generateAuthState();
97 1
            $this->setState('authState', $authState);
98 1
            $defaultParams['state'] = $authState;
99
        }
100
101 1
        return RequestUtil::composeUrl($this->authUrl, array_merge($defaultParams, $params));
102
    }
103
104
    /**
105
     * Generates the auth state value.
106
     *
107
     * @return string auth state value.
108
     */
109 1
    protected function generateAuthState(): string
110
    {
111 1
        $baseString = static::class . '-' . time();
112 1
        if ($this->session->isActive()) {
113 1
            $baseString .= '-' . $this->session->getId();
114
        }
115 1
        return hash('sha256', uniqid($baseString, true));
116
    }
117
118
    /**
119
     * Fetches access token from authorization code.
120
     *
121
     * @param ServerRequestInterface $incomingRequest
122
     * @param string $authCode authorization code, usually comes at GET parameter 'code'.
123
     * @param array $params additional request params.
124
     *
125
     * @return OAuthToken access token.
126
     */
127
    public function fetchAccessToken(
128
        ServerRequestInterface $incomingRequest,
129
        string $authCode,
130
        array $params = []
131
    ): OAuthToken {
132
        if ($this->validateAuthState) {
133
            $authState = $this->getState('authState');
134
            $queryParams = $incomingRequest->getQueryParams();
135
            $bodyParams = $incomingRequest->getParsedBody();
136
            $incomingState = $queryParams['state'] ?? $bodyParams['state'] ?? null;
137
            if ($incomingState !== null || empty($authState) || strcmp($incomingState, $authState) !== 0) {
0 ignored issues
show
Bug introduced by
It seems like $incomingState can also be of type null; however, parameter $string1 of strcmp() 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

137
            if ($incomingState !== null || empty($authState) || strcmp(/** @scrutinizer ignore-type */ $incomingState, $authState) !== 0) {
Loading history...
138
                throw new InvalidArgumentException('Invalid auth state parameter.');
139
            }
140
            $this->removeState('authState');
141
        }
142
143
        $defaultParams = [
144
            'code' => $authCode,
145
            'grant_type' => 'authorization_code',
146
            'redirect_uri' => $this->getReturnUrl($incomingRequest),
147
        ];
148
149
        $request = $this->createRequest('POST', $this->tokenUrl);
150
        $request = RequestUtil::addParams($request, array_merge($defaultParams, $params));
151
        $request = $this->applyClientCredentialsToRequest($request);
152
153
        $response = $this->sendRequest($request);
154
155
        $token = $this->createToken(
156
            [
157
                'setParams' => [Json::decode($response->getBody()->getContents())],
158
            ]
159
        );
160
        $this->setAccessToken($token);
161
162
        return $token;
163
    }
164
165
    /**
166
     * Applies client credentials (e.g. {@see clientId} and {@see clientSecret}) to the HTTP request instance.
167
     * This method should be invoked before sending any HTTP request, which requires client credentials.
168
     *
169
     * @param RequestInterface $request HTTP request instance.
170
     *
171
     * @return RequestInterface
172
     */
173
    protected function applyClientCredentialsToRequest(RequestInterface $request): RequestInterface
174
    {
175
        return RequestUtil::addParams(
176
            $request,
177
            [
178
                'client_id' => $this->clientId,
179
                'client_secret' => $this->clientSecret,
180
            ]
181
        );
182
    }
183
184
    /**
185
     * Creates token from its configuration.
186
     *
187
     * @param array $tokenConfig token configuration.
188
     *
189
     * @return OAuthToken token instance.
190
     */
191
    protected function createToken(array $tokenConfig = []): OAuthToken
192
    {
193
        $tokenConfig['tokenParamKey'] = 'access_token';
194
195
        return parent::createToken($tokenConfig);
196
    }
197
198
    public function applyAccessTokenToRequest(RequestInterface $request, OAuthToken $accessToken): RequestInterface
199
    {
200
        return RequestUtil::addParams(
201
            $request,
202
            [
203
                'access_token' => $accessToken->getToken(),
204
            ]
205
        );
206
    }
207
208
    /**
209
     * Gets new auth token to replace expired one.
210
     *
211
     * @param OAuthToken $token expired auth token.
212
     *
213
     * @return OAuthToken new auth token.
214
     */
215
    public function refreshAccessToken(OAuthToken $token): OAuthToken
216
    {
217
        $params = [
218
            'grant_type' => 'refresh_token',
219
        ];
220
        $params = array_merge($token->getParams(), $params);
221
222
        $request = $this->createRequest('POST', $this->tokenUrl);
223
        $request = RequestUtil::addParams($request, $params);
224
225
        $request = $this->applyClientCredentialsToRequest($request);
226
227
        $response = $this->sendRequest($request);
228
229
        $token = $this->createToken(
230
            [
231
                'setParams' => [Json::decode($response->getBody()->getContents())],
232
            ]
233
        );
234
        $this->setAccessToken($token);
235
236
        return $token;
237
    }
238
239
    /**
240
     * Authenticate OAuth client directly at the provider without third party (user) involved,
241
     * using 'client_credentials' grant type.
242
     *
243
     * @link https://tools.ietf.org/html/rfc6749#section-4.4
244
     *
245
     * @param array $params additional request params.
246
     *
247
     * @return OAuthToken access token.
248
     */
249
    public function authenticateClient(array $params = []): OAuthToken
250
    {
251
        $defaultParams = [
252
            'grant_type' => 'client_credentials',
253
        ];
254
255
        if (!empty($this->getScope())) {
256
            $defaultParams['scope'] = $this->getScope();
257
        }
258
259
        $request = $this->createRequest('POST', $this->tokenUrl);
260
        $request = RequestUtil::addParams(
261
            $request,
262
            array_merge($defaultParams, $params)
263
        );
264
265
        $request = $this->applyClientCredentialsToRequest($request);
266
267
        $response = $this->sendRequest($request);
268
269
        $token = $this->createToken(
270
            [
271
                'setParams' => [Json::decode($response->getBody()->getContents())],
272
            ]
273
        );
274
        $this->setAccessToken($token);
275
276
        return $token;
277
    }
278
279
    /**
280
     * Authenticates user directly by 'username/password' pair, using 'password' grant type.
281
     *
282
     * @link https://tools.ietf.org/html/rfc6749#section-4.3
283
     *
284
     * @param string $username user name.
285
     * @param string $password user password.
286
     * @param array $params additional request params.
287
     *
288
     * @return OAuthToken access token.
289
     */
290
    public function authenticateUser(string $username, string $password, array $params = []): OAuthToken
291
    {
292
        $defaultParams = [
293
            'grant_type' => 'password',
294
            'username' => $username,
295
            'password' => $password,
296
        ];
297
298
        if (!empty($this->getScope())) {
299
            $defaultParams['scope'] = $this->getScope();
300
        }
301
302
        $request = $this->createRequest('POST', $this->tokenUrl);
303
        $request = RequestUtil::addParams(
304
            $request,
305
            array_merge($defaultParams, $params)
306
        );
307
308
        $request = $this->applyClientCredentialsToRequest($request);
309
310
        $response = $this->sendRequest($request);
311
312
        $token = $this->createToken(
313
            [
314
                'setParams' => [Json::decode($response->getBody()->getContents())],
315
            ]
316
        );
317
        $this->setAccessToken($token);
318
319
        return $token;
320
    }
321
322
    /**
323
     * Authenticates user directly using JSON Web Token (JWT).
324
     *
325
     * @link https://tools.ietf.org/html/rfc7515
326
     *
327
     * @param string $username
328
     * @param array|Signature $signature signature method or its array configuration.
329
     * If empty - {@see signatureMethod} will be used.
330
     * @param array $options additional options. Valid options are:
331
     *
332
     * - header: array, additional JWS header parameters.
333
     * - payload: array, additional JWS payload (message or claim-set) parameters.
334
     * - signatureKey: string, signature key to be used, if not set - {@see clientSecret} will be used.
335
     * @param array $params additional request params.
336
     *
337
     * @throws JsonException
338
     *
339
     * @return OAuthToken access token.
340
     */
341
    public function authenticateUserJwt(
342
        string $username,
343
        $signature = null,
344
        array $options = [],
345
        array $params = []
346
    ): OAuthToken {
347
        if (empty($signature)) {
348
            $signatureMethod = $this->getSignatureMethod();
349
        } elseif (is_object($signature)) {
350
            $signatureMethod = $signature;
351
        } else {
352
            $signatureMethod = $this->createSignatureMethod($signature);
353
        }
354
355
        $header = $options['header'] ?? [];
356
        $payload = $options['payload'] ?? [];
357
358
        $header = array_merge(
359
            [
360
                'typ' => 'JWT',
361
            ],
362
            $header
363
        );
364
        if (!isset($header['alg'])) {
365
            $signatureName = $signatureMethod->getName();
366
            if (preg_match('/^([a-z])[a-z]*-([a-z])[a-z]*(\d+)$/i', $signatureName, $matches)) {
367
                // convert 'RSA-SHA256' to 'RS256' :
368
                $signatureName = $matches[1] . $matches[2] . $matches[3];
369
            }
370
            $header['alg'] = $signatureName;
371
        }
372
373
        $payload = array_merge(
374
            [
375
                'iss' => $username,
376
                'scope' => $this->getScope(),
377
                'aud' => $this->tokenUrl,
378
                'iat' => time(),
379
            ],
380
            $payload
381
        );
382
        if (!isset($payload['exp'])) {
383
            $payload['exp'] = $payload['iat'] + 3600;
384
        }
385
386
        $signatureBaseString = base64_encode(Json::encode($header)) . '.' . base64_encode(Json::encode($payload));
387
        $signatureKey = $options['signatureKey'] ?? $this->clientSecret;
388
        $signature = $signatureMethod->generateSignature($signatureBaseString, $signatureKey);
389
390
        $assertion = $signatureBaseString . '.' . $signature;
391
392
        $request = $this->createRequest('POST', $this->tokenUrl);
393
        $request = RequestUtil::addParams(
394
            $request,
395
            array_merge(
396
                [
397
                    'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
398
                    'assertion' => $assertion,
399
                ],
400
                $params
401
            )
402
        );
403
404
        $response = $this->sendRequest($request);
405
406
        $token = $this->createToken(
407
            [
408
                'setParams()' => [Json::decode($response->getBody()->getContents())],
409
            ]
410
        );
411
        $this->setAccessToken($token);
412
413
        return $token;
414
    }
415
416
    public function getClientId(): string
417
    {
418
        return $this->clientId;
419
    }
420
421 1
    public function setClientId(string $clientId): void
422
    {
423 1
        $this->clientId = $clientId;
424
    }
425
426
    public function getClientSecret(): string
427
    {
428
        return $this->clientSecret;
429
    }
430
431
    public function setClientSecret(string $clientSecret): void
432
    {
433
        $this->clientSecret = $clientSecret;
434
    }
435
436
    public function getTokenUrl(): string
437
    {
438
        return $this->tokenUrl;
439
    }
440
441
    public function setTokenUrl(string $tokenUrl): void
442
    {
443
        $this->tokenUrl = $tokenUrl;
444
    }
445
446
    public function withValidateAuthState(): self
447
    {
448
        $new = clone $this;
449
        $new->validateAuthState = true;
450
        return $new;
451
    }
452
453
    public function withoutValidateAuthState(): self
454
    {
455
        $new = clone $this;
456
        $new->validateAuthState = false;
457
        return $new;
458
    }
459
460
    /**
461
     * Composes default {@see returnUrl} value.
462
     *
463
     * @param ServerRequestInterface $request
464
     *
465
     * @return string return URL.
466
     */
467
    protected function defaultReturnUrl(ServerRequestInterface $request): string
468
    {
469
        $params = $request->getQueryParams();
470
        unset($params['code'], $params['state']);
471
472
        return (string)$request->getUri()->withQuery(http_build_query($params, '', '&', PHP_QUERY_RFC3986));
473
    }
474
}
475