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: * A headless OAuth 2.0 client that acquires and validates tokens required for
26: * access to protected resources
27: */
28: abstract class OAuth2Client
29: {
30: private ?HttpServer $Listener;
31: private AbstractProvider $Provider;
32: /** @var OAuth2Flow::* */
33: private int $Flow;
34: private string $TokenKey;
35:
36: /**
37: * Return an HTTP listener to receive OAuth 2.0 redirects from the provider,
38: * or null to disable flows that require it
39: *
40: * Reference implementation:
41: *
42: * ```php
43: * <?php
44: * class OAuth2TestClient extends OAuth2Client
45: * {
46: * protected function getListener(): ?HttpServer
47: * {
48: * $listener = new HttpServer(
49: * Env::get('app_host', 'localhost'),
50: * Env::getInt('app_port', 27755),
51: * );
52: *
53: * $proxyHost = Env::getNullable('app_proxy_host', null);
54: * $proxyPort = Env::getNullableInt('app_proxy_port', null);
55: *
56: * if ($proxyHost !== null && $proxyPort !== null) {
57: * return $listener->withProxy(
58: * $proxyHost,
59: * $proxyPort,
60: * Env::getNullableBool('app_proxy_tls', null),
61: * Env::get('app_proxy_base_path', ''),
62: * );
63: * }
64: *
65: * return $listener;
66: * }
67: * }
68: * ```
69: */
70: abstract protected function getListener(): ?HttpServer;
71:
72: /**
73: * Return an OAuth 2.0 provider to request and validate tokens that
74: * authorize access to the resource server
75: *
76: * Example:
77: *
78: * The following provider could be used to authorize access to the Microsoft
79: * Graph API on behalf of a user or application. `redirectUri` can be
80: * omitted if support for the Authorization Code flow is not required.
81: *
82: * > The only scope required for access to the Microsoft Graph API is
83: * > `https://graph.microsoft.com/.default`
84: *
85: * ```php
86: * <?php
87: * class OAuth2TestClient extends OAuth2Client
88: * {
89: * protected function getProvider(): GenericProvider
90: * {
91: * return new GenericProvider([
92: * 'clientId' => $this->AppId,
93: * 'clientSecret' => $this->Secret,
94: * 'redirectUri' => $this->getRedirectUri(),
95: * 'urlAuthorize' => sprintf('https://login.microsoftonline.com/%s/oauth2/authorize', $this->TenantId),
96: * 'urlAccessToken' => sprintf('https://login.microsoftonline.com/%s/oauth2/v2.0/token', $this->TenantId),
97: * 'urlResourceOwnerDetails' => sprintf('https://login.microsoftonline.com/%s/openid/userinfo', $this->TenantId),
98: * 'scopes' => ['openid', 'profile', 'email', 'offline_access', 'https://graph.microsoft.com/.default'],
99: * 'scopeSeparator' => ' ',
100: * ]);
101: * }
102: * }
103: * ```
104: */
105: abstract protected function getProvider(): AbstractProvider;
106:
107: /**
108: * Return the OAuth 2.0 flow to use
109: *
110: * @return OAuth2Flow::*
111: */
112: abstract protected function getFlow(): int;
113:
114: /**
115: * Return the URL of the OAuth 2.0 provider's JSON Web Key Set, or null to
116: * disable JWT signature validation and decoding
117: *
118: * Required for token signature validation. Check the provider's
119: * `https://server.com/.well-known/openid-configuration` if unsure.
120: */
121: abstract protected function getJsonWebKeySetUrl(): ?string;
122:
123: /**
124: * Called when an access token is received from the OAuth 2.0 provider
125: *
126: * @param array<string,mixed>|null $idToken
127: * @param OAuth2GrantType::* $grantType
128: */
129: abstract protected function receiveToken(AccessToken $token, ?array $idToken, string $grantType): void;
130:
131: /**
132: * Creates a new OAuth2Client object
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: * Get the URI that receives redirects from the OAuth 2.0 provider
150: *
151: * Returns `null` if {@see OAuth2Client::getListener()} does not return an
152: * HTTP listener.
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: * Get an OAuth 2.0 access token from the cache if possible, otherwise use a
163: * refresh token to acquire one from the provider if possible, otherwise
164: * flush all tokens and authorize with the provider from scratch
165: *
166: * @param string[]|null $scopes
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: * False if one or more scopes are not present in the token
200: *
201: * @param string[]|null $scopes
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: * If an unexpired refresh token is available, use it to get a new access
213: * token from the provider if possible
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: * Get an access token from the OAuth 2.0 provider
228: *
229: * @param array<string,mixed> $options
230: */
231: final protected function authorize(array $options = []): AccessToken
232: {
233: if (isset($options['scope'])) {
234: $scopes = $this->filterScope($options['scope']);
235:
236: // If an unexpired access or refresh token is available, extend the
237: // scope of the most recently issued access token
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: // Always include the provider's default scopes
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: * @param array<string,mixed> $options
270: */
271: private function authorizeWithClientCredentials(array $options = []): AccessToken
272: {
273: // league/oauth2-client doesn't add scopes to client_credentials
274: // requests
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: * @param array<string,mixed> $options
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: /** @todo Call xdg-open or similar here */
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: * @param mixed $return
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: * Request an access token from the OAuth 2.0 provider, then validate, cache
373: * and return it
374: *
375: * @param string&OAuth2GrantType::* $grantType
376: * @param array<string,mixed> $options
377: * @param mixed $scope
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: // OAuth 2.0 clients shouldn't inspect tokens they aren't party to, but
400: // some providers rely on non-compliant behaviour, e.g. Xero surfaces
401: // `authentication_event_id` as an access token claim
402: $expires = $_token->getExpires();
403: if ($expires === null) {
404: /** @var int|null */
405: $expires = $claims['exp'] ?? null;
406: }
407:
408: // In order of preference, get authorized scopes from:
409: //
410: // - The 'scope' value in the HTTP response
411: // - The 'scope' claim in the access token
412: // - The 'scope' sent with the request (Client Credentials flow only)
413: // - The 'scope' sent with the authorization request
414: // - The expired token (grant type 'refresh_token' only)
415: // - The provider's default scopes
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: // Keep the ID token until access and/or refresh tokens expire
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: * Remove any tokens issued by the OAuth 2.0 provider from the cache
455: *
456: * @return $this
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: * Validate and decode a token issued by the OAuth 2.0 provider
471: *
472: * The provider's JSON Web Key Set (JWKS) is required for signature
473: * validation, so {@see OAuth2Client::getJsonWebKeySetUrl()} must return a
474: * URL.
475: *
476: * If JWT signature validation fails, an exception is thrown if `$required`
477: * is `true`, otherwise `null` is returned to the caller.
478: *
479: * Access tokens should be presented to resource servers even if they can't
480: * be validated. Technically, the only party inspecting an OAuth 2.0 token
481: * should be its intended "aud"ience, and in some cases (e.g. when the
482: * Microsoft Identity Platform issues an access token for the Microsoft
483: * Graph API), token signatures are deliberately broken to discourage
484: * inspection (e.g. by adding a nonce to the header).
485: *
486: * @return array<string,mixed>
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: // If there are any keys with no "alg"orithm (hello, Microsoft Identity
496: // Platform), `JWK::parseKeySet()` fails, so extract "alg" from the
497: // token and pass it to `JWK::parseKeySet()`
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: // Refresh the JWKS when signature validation fails
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: * @param array{keys:array<array<string,string[]|string>>} $jwks
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: /** @var string|null */
546: return $header['alg'] ?? null;
547: }
548:
549: /**
550: * @return array{keys:array<array<string,string[]|string>>}|null
551: */
552: private function getJsonWebKeySet(bool $refresh = false): ?array
553: {
554: $url = $this->getJsonWebKeySetUrl();
555: if ($url === null) {
556: return null;
557: }
558:
559: /** @var array{keys:array<array<string,string[]|string>>} */
560: return Curler::build()
561: ->uri($url)
562: ->cacheResponses()
563: ->cacheLifetime(0)
564: ->refreshCache($refresh)
565: ->get();
566: }
567:
568: /**
569: * Get the decoded ID token most recently issued with an access token by the
570: * OAuth 2.0 provider
571: *
572: * @return array<string,mixed>|null
573: */
574: final public function getIdToken(): ?array
575: {
576: // Don't [re-]authorize for an ID token that won't be issued
577: $cache = Cache::asOfNow();
578: if (
579: $cache->has($this->TokenKey)
580: && !$cache->has("{$this->TokenKey}:id")
581: ) {
582: return null;
583: }
584:
585: // Don't return stale identity information
586: if ($cache->has($this->TokenKey)) {
587: /** @var array<string,mixed>|null */
588: return $cache->getArray("{$this->TokenKey}:id");
589: }
590:
591: $cache->close();
592: $this->getAccessToken();
593:
594: /** @var array<string,mixed>|null */
595: return Cache::getArray("{$this->TokenKey}:id");
596: }
597:
598: /**
599: * @return string[]
600: */
601: private function getDefaultScopes()
602: {
603: return (function () {
604: /** @var AbstractProvider $this */
605: // @phpstan-ignore method.protected
606: return $this->getDefaultScopes();
607: })->bindTo($this->Provider, $this->Provider)();
608: }
609:
610: /**
611: * @return string
612: */
613: private function getScopeSeparator()
614: {
615: return (function () {
616: /** @var AbstractProvider $this */
617: // @phpstan-ignore method.protected
618: return $this->getScopeSeparator();
619: })->bindTo($this->Provider, $this->Provider)();
620: }
621:
622: /**
623: * @param mixed $scope
624: * @return string[]
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: