1: | <?php declare(strict_types=1); |
2: | |
3: | namespace Salient\Http\OAuth2; |
4: | |
5: | use Firebase\JWT\JWK; |
6: | use Firebase\JWT\JWT; |
7: | use Firebase\JWT\SignatureInvalidException; |
8: | use League\OAuth2\Client\Provider\AbstractProvider; |
9: | use Salient\Contract\Http\HttpRequestMethod as Method; |
10: | use Salient\Contract\Http\HttpServerRequestInterface; |
11: | use Salient\Core\Facade\Cache; |
12: | use Salient\Core\Facade\Console; |
13: | use Salient\Curler\Curler; |
14: | use Salient\Http\HttpResponse; |
15: | use Salient\Http\HttpServer; |
16: | use Salient\Utility\Arr; |
17: | use Salient\Utility\Get; |
18: | use Salient\Utility\Json; |
19: | use Salient\Utility\Str; |
20: | use LogicException; |
21: | use Throwable; |
22: | use UnexpectedValueException; |
23: | |
24: | |
25: | |
26: | |
27: | |
28: | abstract class OAuth2Client |
29: | { |
30: | private ?HttpServer $Listener; |
31: | private AbstractProvider $Provider; |
32: | |
33: | private int $Flow; |
34: | private string $TokenKey; |
35: | |
36: | |
37: | |
38: | |
39: | |
40: | |
41: | |
42: | |
43: | |
44: | |
45: | |
46: | |
47: | |
48: | |
49: | |
50: | |
51: | |
52: | |
53: | |
54: | |
55: | |
56: | |
57: | |
58: | |
59: | |
60: | |
61: | |
62: | |
63: | |
64: | |
65: | |
66: | |
67: | |
68: | |
69: | |
70: | abstract protected function getListener(): ?HttpServer; |
71: | |
72: | |
73: | |
74: | |
75: | |
76: | |
77: | |
78: | |
79: | |
80: | |
81: | |
82: | |
83: | |
84: | |
85: | |
86: | |
87: | |
88: | |
89: | |
90: | |
91: | |
92: | |
93: | |
94: | |
95: | |
96: | |
97: | |
98: | |
99: | |
100: | |
101: | |
102: | |
103: | |
104: | |
105: | abstract protected function getProvider(): AbstractProvider; |
106: | |
107: | |
108: | |
109: | |
110: | |
111: | |
112: | abstract protected function getFlow(): int; |
113: | |
114: | |
115: | |
116: | |
117: | |
118: | |
119: | |
120: | |
121: | abstract protected function getJsonWebKeySetUrl(): ?string; |
122: | |
123: | |
124: | |
125: | |
126: | |
127: | |
128: | |
129: | abstract protected function receiveToken(AccessToken $token, ?array $idToken, string $grantType): void; |
130: | |
131: | |
132: | |
133: | |
134: | public function __construct() |
135: | { |
136: | $this->Listener = $this->getListener(); |
137: | $this->Provider = $this->getProvider(); |
138: | $this->Flow = $this->getFlow(); |
139: | $this->TokenKey = implode(':', [ |
140: | static::class, |
141: | 'oauth2', |
142: | $this->Provider->getBaseAuthorizationUrl(), |
143: | $this->Flow, |
144: | 'token', |
145: | ]); |
146: | } |
147: | |
148: | |
149: | |
150: | |
151: | |
152: | |
153: | |
154: | final protected function getRedirectUri(): ?string |
155: | { |
156: | return $this->Listener |
157: | ? sprintf('%s/oauth2/callback', $this->Listener->getBaseUri()) |
158: | : null; |
159: | } |
160: | |
161: | |
162: | |
163: | |
164: | |
165: | |
166: | |
167: | |
168: | final public function getAccessToken(?array $scopes = null): AccessToken |
169: | { |
170: | $token = Cache::getInstance()->getInstanceOf($this->TokenKey, AccessToken::class); |
171: | if ($token) { |
172: | if ($this->accessTokenHasScopes($token, $scopes)) { |
173: | return $token; |
174: | } |
175: | |
176: | Console::debug('Cached token has missing scopes; re-authorizing'); |
177: | return $this->authorize(['scope' => $scopes]); |
178: | } |
179: | |
180: | try { |
181: | $token = $this->refreshAccessToken(); |
182: | if ($token) { |
183: | if ($this->accessTokenHasScopes($token, $scopes)) { |
184: | return $token; |
185: | } |
186: | Console::debug('New token has missing scopes'); |
187: | } |
188: | } catch (Throwable $ex) { |
189: | Console::debug( |
190: | sprintf('refresh_token failed with __%s__:', get_class($ex)), |
191: | $ex->getMessage() |
192: | ); |
193: | } |
194: | |
195: | return $this->authorize($scopes ? ['scope' => $scopes] : []); |
196: | } |
197: | |
198: | |
199: | |
200: | |
201: | |
202: | |
203: | private function accessTokenHasScopes(AccessToken $token, ?array $scopes): bool |
204: | { |
205: | if ($scopes && array_diff($scopes, $token->Scopes)) { |
206: | return false; |
207: | } |
208: | return true; |
209: | } |
210: | |
211: | |
212: | |
213: | |
214: | |
215: | final protected function refreshAccessToken(): ?AccessToken |
216: | { |
217: | $refreshToken = Cache::getString("{$this->TokenKey}:refresh"); |
218: | return $refreshToken === null |
219: | ? null |
220: | : $this->requestAccessToken( |
221: | OAuth2GrantType::REFRESH_TOKEN, |
222: | ['refresh_token' => $refreshToken] |
223: | ); |
224: | } |
225: | |
226: | |
227: | |
228: | |
229: | |
230: | |
231: | final protected function authorize(array $options = []): AccessToken |
232: | { |
233: | if (isset($options['scope'])) { |
234: | $scopes = $this->filterScope($options['scope']); |
235: | |
236: | |
237: | |
238: | $cache = Cache::asOfNow(); |
239: | if ( |
240: | $cache->has($this->TokenKey) |
241: | || $cache->has("{$this->TokenKey}:refresh") |
242: | ) { |
243: | $lastToken = $cache->getInstanceOf($this->TokenKey, AccessToken::class); |
244: | if ($lastToken) { |
245: | $scopes = Arr::extend($lastToken->Scopes, ...$scopes); |
246: | } |
247: | } |
248: | $cache->close(); |
249: | |
250: | |
251: | $options['scope'] = Arr::extend($this->getDefaultScopes(), ...$scopes); |
252: | } |
253: | |
254: | $this->flushTokens(); |
255: | |
256: | switch ($this->Flow) { |
257: | case OAuth2Flow::CLIENT_CREDENTIALS: |
258: | return $this->authorizeWithClientCredentials($options); |
259: | |
260: | case OAuth2Flow::AUTHORIZATION_CODE: |
261: | return $this->authorizeWithAuthorizationCode($options); |
262: | |
263: | default: |
264: | throw new LogicException(sprintf('Invalid OAuth2Flow: %d', $this->Flow)); |
265: | } |
266: | } |
267: | |
268: | |
269: | |
270: | |
271: | private function authorizeWithClientCredentials(array $options = []): AccessToken |
272: | { |
273: | |
274: | |
275: | $scopes = null; |
276: | if (!isset($options['scope'])) { |
277: | $scopes = $this->getDefaultScopes() ?: null; |
278: | } elseif (is_array($options['scope'])) { |
279: | $scopes = $options['scope']; |
280: | } |
281: | |
282: | if ($scopes !== null) { |
283: | $separator = $this->getScopeSeparator(); |
284: | $options['scope'] = implode($separator, $scopes); |
285: | } |
286: | |
287: | return $this->requestAccessToken( |
288: | OAuth2GrantType::CLIENT_CREDENTIALS, |
289: | $options |
290: | ); |
291: | } |
292: | |
293: | |
294: | |
295: | |
296: | private function authorizeWithAuthorizationCode(array $options = []): AccessToken |
297: | { |
298: | if (!$this->Listener) { |
299: | throw new LogicException('Cannot use the Authorization Code flow without a Listener'); |
300: | } |
301: | |
302: | $url = $this->Provider->getAuthorizationUrl($options); |
303: | $state = $this->Provider->getState(); |
304: | Cache::set("{$this->TokenKey}:state", $state); |
305: | |
306: | Console::debug( |
307: | 'Starting HTTP server to receive authorization_code:', |
308: | sprintf( |
309: | '%s:%d', |
310: | $this->Listener->getHost(), |
311: | $this->Listener->getPort(), |
312: | ) |
313: | ); |
314: | |
315: | $this->Listener->start(); |
316: | try { |
317: | |
318: | Console::log('Follow the link to authorize access:', "\n$url"); |
319: | Console::info('Waiting for authorization'); |
320: | $code = $this->Listener->listen( |
321: | fn(HttpServerRequestInterface $request, bool &$continue, &$return): HttpResponse => |
322: | $this->receiveAuthorizationCode($request, $continue, $return) |
323: | ); |
324: | } finally { |
325: | $this->Listener->stop(); |
326: | } |
327: | |
328: | if ($code === null) { |
329: | throw new OAuth2Exception('OAuth 2.0 provider did not return an authorization code'); |
330: | } |
331: | |
332: | return $this->requestAccessToken( |
333: | OAuth2GrantType::AUTHORIZATION_CODE, |
334: | ['code' => $code], |
335: | $options['scope'] ?? null |
336: | ); |
337: | } |
338: | |
339: | |
340: | |
341: | |
342: | private function receiveAuthorizationCode(HttpServerRequestInterface $request, bool &$continue, &$return): HttpResponse |
343: | { |
344: | if ( |
345: | Str::upper($request->getMethod()) !== Method::GET |
346: | || $request->getUri()->getPath() !== '/oauth2/callback' |
347: | ) { |
348: | $continue = true; |
349: | return new HttpResponse(400, 'Invalid request.'); |
350: | } |
351: | |
352: | $state = Cache::getString("{$this->TokenKey}:state"); |
353: | Cache::delete("{$this->TokenKey}:state"); |
354: | parse_str($request->getUri()->getQuery(), $fields); |
355: | $code = $fields['code'] ?? null; |
356: | |
357: | if ( |
358: | $state !== null |
359: | && $state === ($fields['state'] ?? null) |
360: | && $code !== null |
361: | ) { |
362: | Console::debug('Authorization code received and verified'); |
363: | $return = $code; |
364: | return new HttpResponse(200, 'Authorization received. You may now close this window.'); |
365: | } |
366: | |
367: | Console::debug('Request did not provide a valid authorization code'); |
368: | return new HttpResponse(400, 'Invalid request. Please try again.'); |
369: | } |
370: | |
371: | |
372: | |
373: | |
374: | |
375: | |
376: | |
377: | |
378: | |
379: | private function requestAccessToken( |
380: | string $grantType, |
381: | array $options = [], |
382: | $scope = null |
383: | ): AccessToken { |
384: | Console::debug('Requesting access token with ' . $grantType); |
385: | |
386: | $_token = $this->Provider->getAccessToken($grantType, $options); |
387: | $_values = $_token->getValues(); |
388: | |
389: | $tokenType = $_values['token_type'] ?? null; |
390: | if ($tokenType === null) { |
391: | throw new OAuth2Exception('OAuth 2.0 provider did not return a token type'); |
392: | } |
393: | |
394: | $accessToken = $_token->getToken(); |
395: | $refreshToken = $_token->getRefreshToken(); |
396: | $idToken = $_values['id_token'] ?? null; |
397: | $claims = $this->getValidJsonWebToken($accessToken) ?: []; |
398: | |
399: | |
400: | |
401: | |
402: | $expires = $_token->getExpires(); |
403: | if ($expires === null) { |
404: | |
405: | $expires = $claims['exp'] ?? null; |
406: | } |
407: | |
408: | |
409: | |
410: | |
411: | |
412: | |
413: | |
414: | |
415: | |
416: | $scopes = $this->filterScope($_values['scope'] |
417: | ?? $claims['scope'] |
418: | ?? $options['scope'] |
419: | ?? $scope); |
420: | |
421: | if (!$scopes && $grantType === OAuth2GrantType::REFRESH_TOKEN) { |
422: | $lastToken = Cache::getInstance()->getInstanceOf($this->TokenKey, AccessToken::class); |
423: | if ($lastToken) { |
424: | $scopes = $lastToken->Scopes; |
425: | } |
426: | } |
427: | |
428: | $token = new AccessToken( |
429: | $accessToken, |
430: | $tokenType, |
431: | $expires, |
432: | $scopes ?: $this->getDefaultScopes(), |
433: | $claims |
434: | ); |
435: | |
436: | Cache::set($this->TokenKey, $token, $token->Expires); |
437: | |
438: | if ($idToken !== null) { |
439: | $idToken = $this->getValidJsonWebToken($idToken, true); |
440: | |
441: | Cache::set("{$this->TokenKey}:id", $idToken); |
442: | } |
443: | |
444: | if ($refreshToken !== null) { |
445: | Cache::set("{$this->TokenKey}:refresh", $refreshToken); |
446: | } |
447: | |
448: | $this->receiveToken($token, $idToken, $grantType); |
449: | |
450: | return $token; |
451: | } |
452: | |
453: | |
454: | |
455: | |
456: | |
457: | |
458: | final public function flushTokens() |
459: | { |
460: | Console::debug('Flushing cached tokens'); |
461: | Cache::delete($this->TokenKey); |
462: | Cache::delete("{$this->TokenKey}:id"); |
463: | Cache::delete("{$this->TokenKey}:refresh"); |
464: | Cache::delete("{$this->TokenKey}:state"); |
465: | |
466: | return $this; |
467: | } |
468: | |
469: | |
470: | |
471: | |
472: | |
473: | |
474: | |
475: | |
476: | |
477: | |
478: | |
479: | |
480: | |
481: | |
482: | |
483: | |
484: | |
485: | |
486: | |
487: | |
488: | private function getValidJsonWebToken(string $token, bool $required = false, bool $refreshKeys = false, ?string $alg = null): ?array |
489: | { |
490: | $jwks = $this->getJsonWebKeySet($refreshKeys); |
491: | if ($jwks === null) { |
492: | return null; |
493: | } |
494: | |
495: | |
496: | |
497: | |
498: | if ($alg === null && $this->jwksHasKeyWithNoAlgorithm($jwks)) { |
499: | $alg = $this->getTokenAlgorithm($token); |
500: | } |
501: | |
502: | try { |
503: | return (array) JWT::decode( |
504: | $token, |
505: | JWK::parseKeySet($jwks, $alg) |
506: | ); |
507: | } catch (SignatureInvalidException|UnexpectedValueException $ex) { |
508: | |
509: | if (!$refreshKeys) { |
510: | return $this->getValidJsonWebToken($token, $required, true, $alg); |
511: | } |
512: | if ($required) { |
513: | throw $ex; |
514: | } |
515: | return null; |
516: | } |
517: | } |
518: | |
519: | |
520: | |
521: | |
522: | private function jwksHasKeyWithNoAlgorithm(array $jwks): bool |
523: | { |
524: | foreach ($jwks['keys'] as $key) { |
525: | if (!isset($key['alg'])) { |
526: | return true; |
527: | } |
528: | } |
529: | return false; |
530: | } |
531: | |
532: | private function getTokenAlgorithm(string $token): ?string |
533: | { |
534: | $parts = explode('.', $token); |
535: | if (count($parts) !== 3) { |
536: | return null; |
537: | } |
538: | |
539: | $header = base64_decode(strtr($parts[0], '-_', '+/'), true); |
540: | if ($header === false) { |
541: | return null; |
542: | } |
543: | |
544: | $header = Json::parseObjectAsArray($header); |
545: | |
546: | return $header['alg'] ?? null; |
547: | } |
548: | |
549: | |
550: | |
551: | |
552: | private function getJsonWebKeySet(bool $refresh = false): ?array |
553: | { |
554: | $url = $this->getJsonWebKeySetUrl(); |
555: | if ($url === null) { |
556: | return null; |
557: | } |
558: | |
559: | |
560: | return Curler::build() |
561: | ->uri($url) |
562: | ->cacheResponses() |
563: | ->cacheLifetime(0) |
564: | ->refreshCache($refresh) |
565: | ->get(); |
566: | } |
567: | |
568: | |
569: | |
570: | |
571: | |
572: | |
573: | |
574: | final public function getIdToken(): ?array |
575: | { |
576: | |
577: | $cache = Cache::asOfNow(); |
578: | if ( |
579: | $cache->has($this->TokenKey) |
580: | && !$cache->has("{$this->TokenKey}:id") |
581: | ) { |
582: | return null; |
583: | } |
584: | |
585: | |
586: | if ($cache->has($this->TokenKey)) { |
587: | |
588: | return $cache->getArray("{$this->TokenKey}:id"); |
589: | } |
590: | |
591: | $cache->close(); |
592: | $this->getAccessToken(); |
593: | |
594: | |
595: | return Cache::getArray("{$this->TokenKey}:id"); |
596: | } |
597: | |
598: | |
599: | |
600: | |
601: | private function getDefaultScopes() |
602: | { |
603: | return (function () { |
604: | |
605: | |
606: | return $this->getDefaultScopes(); |
607: | })->bindTo($this->Provider, $this->Provider)(); |
608: | } |
609: | |
610: | |
611: | |
612: | |
613: | private function getScopeSeparator() |
614: | { |
615: | return (function () { |
616: | |
617: | |
618: | return $this->getScopeSeparator(); |
619: | })->bindTo($this->Provider, $this->Provider)(); |
620: | } |
621: | |
622: | |
623: | |
624: | |
625: | |
626: | private function filterScope($scope): array |
627: | { |
628: | if ($scope === null) { |
629: | return []; |
630: | } |
631: | if (is_string($scope)) { |
632: | return Arr::trim(explode(' ', $scope)); |
633: | } |
634: | if (!Arr::ofString($scope, true)) { |
635: | throw new LogicException(sprintf( |
636: | "'scope' must be of type string[]|string|null, %s given", |
637: | Get::type($scope), |
638: | )); |
639: | } |
640: | return array_values($scope); |
641: | } |
642: | } |
643: | |