From 208d281cabc24bb0ab6b1184cc1cbb7222dc811f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:35:34 +0000 Subject: [PATCH 01/14] support: refresh config when swapping containers Keep the manager configuration repository aligned with its active container when tests or rebinding replace the application instance. This removes the need for package-specific rebinding workarounds and adds coverage proving cached managers resolve configuration from the replacement container. --- src/support/src/Manager.php | 7 ++++--- tests/Support/ManagerTest.php | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/support/src/Manager.php b/src/support/src/Manager.php index 90fcdf2e1..600cc71f2 100644 --- a/src/support/src/Manager.php +++ b/src/support/src/Manager.php @@ -142,15 +142,16 @@ public function getContainer(): Container /** * Set the container instance used by the manager. * - * Tests only. Swaps the singleton's container reference; per-request use - * races across coroutines and breaks every concurrent driver resolution - * through this manager. + * Tests only. Swaps the singleton's container and configuration references; + * per-request use races across coroutines and breaks every concurrent driver + * resolution through this manager. * * @return $this */ public function setContainer(Container $container): static { $this->container = $container; + $this->config = $container->make('config'); return $this; } diff --git a/tests/Support/ManagerTest.php b/tests/Support/ManagerTest.php index ae137b806..b4da95a4f 100644 --- a/tests/Support/ManagerTest.php +++ b/tests/Support/ManagerTest.php @@ -38,6 +38,19 @@ public function testNullAndEmptyStringSelectTheDefaultDriver(): void $this->assertSame('zero', $manager->driver(ManagerIntegerIdentifier::Zero)); } + public function testSetContainerRefreshesTheConfigurationRepository(): void + { + $manager = $this->createManager(); + $container = new Container; + $configuration = new Repository(['source' => 'replacement']); + $container->instance('config', $configuration); + + $manager->setContainer($container); + + $this->assertSame($container, $manager->getContainer()); + $this->assertSame($configuration, $manager->getConfigurationRepository()); + } + protected function createManager(): EnumIdentifierManager { $container = new Container; @@ -53,6 +66,11 @@ public function getDefaultDriver(): string { return 'default'; } + + public function getConfigurationRepository(): Repository + { + return $this->config; + } } enum ManagerUnitIdentifier From 1c8e0e43b53cf8e449a9ef32a57ec24dfd2c5eb2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:35:40 +0000 Subject: [PATCH 02/14] object-pool: share manager and recycler identities Alias the concrete pool manager and recycler to their canonical contracts so concrete and contract consumers use the same worker-lifetime registries. Preserve application overrides while adding focused provider coverage for shared pool and recycler state. --- .../src/ObjectPoolServiceProvider.php | 4 +- .../ObjectPoolServiceProviderTest.php | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 tests/ObjectPool/ObjectPoolServiceProviderTest.php diff --git a/src/object-pool/src/ObjectPoolServiceProvider.php b/src/object-pool/src/ObjectPoolServiceProvider.php index 5f742ffa0..739df4c3d 100644 --- a/src/object-pool/src/ObjectPoolServiceProvider.php +++ b/src/object-pool/src/ObjectPoolServiceProvider.php @@ -17,9 +17,9 @@ class ObjectPoolServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->singleton(Factory::class, PoolManager::class); + $this->app->alias(PoolManager::class, Factory::class); - $this->app->singleton(Recycler::class, PoolRecycler::class); + $this->app->alias(PoolRecycler::class, Recycler::class); } /** diff --git a/tests/ObjectPool/ObjectPoolServiceProviderTest.php b/tests/ObjectPool/ObjectPoolServiceProviderTest.php new file mode 100644 index 000000000..04eef6885 --- /dev/null +++ b/tests/ObjectPool/ObjectPoolServiceProviderTest.php @@ -0,0 +1,39 @@ +app->make(PoolManager::class); + $pool = $manager->pool('shared', static fn () => new stdClass); + + $this->assertSame($manager, $this->app->make(Factory::class)); + $this->assertSame($pool, $this->app->make(Factory::class)->get('shared')); + } + + public function testConcreteRecyclerAndContractShareOneTimerOwner(): void + { + $recycler = $this->app->make(PoolRecycler::class); + $recycler->setInterval(2.5); + + $this->assertSame($recycler, $this->app->make(Recycler::class)); + $this->assertSame(2.5, $this->app->make(Recycler::class)->getInterval()); + } +} From cce9253e12ac9693330bd06f2aff66df1941433e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:35:45 +0000 Subject: [PATCH 03/14] reverb: share the array channel repository Alias the array channel manager to the channel-manager contract when the application has not supplied its own binding. This keeps concrete and contract resolutions on one worker-local repository while preserving both early and late application overrides. --- src/reverb/src/ReverbServiceProvider.php | 2 +- tests/Reverb/ReverbServiceProviderTest.php | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/reverb/src/ReverbServiceProvider.php b/src/reverb/src/ReverbServiceProvider.php index 90a9181eb..80abe29de 100644 --- a/src/reverb/src/ReverbServiceProvider.php +++ b/src/reverb/src/ReverbServiceProvider.php @@ -90,7 +90,7 @@ public function register(): void $this->app->singleton(ServerProviderManager::class); if (! $this->app->bound(ChannelManager::class)) { - $this->app->singleton(ChannelManager::class, ArrayChannelManager::class); + $this->app->alias(ArrayChannelManager::class, ChannelManager::class); } if (! $this->app->bound(ChannelConnectionManager::class)) { diff --git a/tests/Reverb/ReverbServiceProviderTest.php b/tests/Reverb/ReverbServiceProviderTest.php index bcbf14b1a..4f116c0c4 100644 --- a/tests/Reverb/ReverbServiceProviderTest.php +++ b/tests/Reverb/ReverbServiceProviderTest.php @@ -5,10 +5,12 @@ namespace Hypervel\Tests\Reverb; use Hypervel\Redis\RedisProxy; +use Hypervel\Reverb\Contracts\ApplicationProvider; use Hypervel\Reverb\Contracts\Logger; use Hypervel\Reverb\Loggers\NullLogger; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelConnectionManager; use Hypervel\Reverb\Protocols\Pusher\Contracts\ChannelManager; +use Hypervel\Reverb\Protocols\Pusher\Managers\ArrayChannelManager; use Hypervel\Reverb\ReverbServiceProvider; use Hypervel\Reverb\Webhooks\WebhookBatchBuffer; use Hypervel\Support\Facades\Log; @@ -116,6 +118,25 @@ public function testPreservesCustomChannelManagerBindings(): void $this->assertSame($channelConnectionManager, $this->app->make(ChannelConnectionManager::class)); } + public function testConcreteAndContractChannelManagersShareOneRepository(): void + { + $application = $this->app->make(ApplicationProvider::class)->all()->first(); + $manager = $this->app->make(ArrayChannelManager::class); + $channel = $manager->for($application)->findOrCreate('public-shared'); + + $this->assertSame($manager, $this->app->make(ChannelManager::class)); + $this->assertSame($channel, $this->app->make(ChannelManager::class)->for($application)->find('public-shared')); + } + + public function testChannelManagerBindingAfterRegistrationReplacesTheDefaultAlias(): void + { + $channelManager = m::mock(ChannelManager::class); + + $this->app->instance(ChannelManager::class, $channelManager); + + $this->assertSame($channelManager, $this->app->make(ChannelManager::class)); + } + public function testRegistersTheDefaultLoggerOnlyWhenUnbound(): void { $this->assertInstanceOf(NullLogger::class, $this->app->make(Logger::class)); From d5466a5960ab8836eb258a653c9ee88adc0997c3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:37:07 +0000 Subject: [PATCH 04/14] socialite: isolate cached provider state Give each cached provider a monotonic process-lifetime context namespace, move request ownership into coroutine context, and refresh the active request whenever a cached driver is resolved. Unify concrete manager and Factory resolution without breaking Factory-only fakes, remove duplicate rebinding behavior, protect raw provider-context internals, regenerate facade metadata, and make manager and fake return contracts truthful. The regressions cover tenant isolation, request reuse failures, enum drivers, config key edge cases, and manager rebinding. --- src/socialite/src/AbstractProvider.php | 43 +++++----- .../src/Concerns/HasProviderContext.php | 69 ++++++++++++++++ src/socialite/src/Contracts/Factory.php | 2 +- src/socialite/src/HasProviderContext.php | 41 ---------- src/socialite/src/Socialite.php | 30 ++----- src/socialite/src/SocialiteManager.php | 60 ++++---------- .../src/SocialiteServiceProvider.php | 2 +- src/socialite/src/Testing/SocialiteFake.php | 3 +- tests/Socialite/AbstractProviderTest.php | 80 ++++++++++++++++--- .../Fixtures/GenericTestProviderStub.php | 16 ++++ tests/Socialite/SocialiteManagerTest.php | 70 +++++++++++----- 11 files changed, 251 insertions(+), 165 deletions(-) create mode 100644 src/socialite/src/Concerns/HasProviderContext.php delete mode 100644 src/socialite/src/HasProviderContext.php diff --git a/src/socialite/src/AbstractProvider.php b/src/socialite/src/AbstractProvider.php index 2cc4ad72e..8b4d8cd91 100644 --- a/src/socialite/src/AbstractProvider.php +++ b/src/socialite/src/AbstractProvider.php @@ -6,16 +6,12 @@ use GuzzleHttp\Client; use Hypervel\Http\Request; +use Hypervel\Socialite\Concerns\HasProviderContext; use Hypervel\Support\Arr; use Hypervel\Support\Str; +use LogicException; +use SensitiveParameter; -/** - * Base class for all federated login providers. - * - * Provides protocol-agnostic infrastructure: HTTP client, request handling, - * coroutine-safe configuration, state management, and custom parameters. - * Protocol-specific subclasses (OAuth2, SAML2, etc.) extend this. - */ abstract class AbstractProvider { use HasProviderContext; @@ -42,20 +38,18 @@ abstract class AbstractProvider /** * Create a new provider instance. */ - public function __construct( - protected Request $request, - protected array $guzzle = [] - ) { + public function __construct(Request $request, protected array $guzzle = []) + { + $this->setRequest($request); } /** * Set the baseline provider configuration. * - * Called once at registration time (e.g. from a builder or an extend callback). - * Writes to the instance property so config survives across coroutines. - * For per-request overrides, use setConfig() instead. + * Boot-only. The configuration persists for the worker lifetime and affects + * every subsequent request. Use setConfig() for per-request overrides. */ - public function withConfig(array $config): static + public function withConfig(#[SensitiveParameter] array $config): static { $this->additionalConfig = $config; @@ -65,10 +59,10 @@ public function withConfig(array $config): static /** * Override provider configuration for the current request. * - * Writes to coroutine context for Swoole safety. Merges with the current - * effective config so partial overrides preserve baseline keys. + * The override is isolated to the current coroutine and must be applied + * independently on the redirect and callback requests. */ - public function setConfig(array $config): static + public function setConfig(#[SensitiveParameter] array $config): static { $this->setContext('additionalConfig', array_replace($this->getConfig(), $config)); @@ -84,7 +78,7 @@ protected function getConfig(?string $key = null, mixed $default = null): mixed { $config = $this->getContext('additionalConfig', $this->additionalConfig); - return $key ? Arr::get($config, $key, $default) : $config; + return Arr::get($config, $key, $default); } /** @@ -125,8 +119,15 @@ public function setRequest(Request $request): static */ protected function getRequest(): Request { - // @phpstan-ignore-next-line getContext('request') is only written by setRequest(Request). - return $this->getContext('request', $this->request); + $request = $this->getContext('request'); + + if (! $request instanceof Request) { + throw new LogicException( + 'No request is available for this provider. Resolve it through Socialite::driver() or call setRequest().' + ); + } + + return $request; } /** diff --git a/src/socialite/src/Concerns/HasProviderContext.php b/src/socialite/src/Concerns/HasProviderContext.php new file mode 100644 index 000000000..d951b54ea --- /dev/null +++ b/src/socialite/src/Concerns/HasProviderContext.php @@ -0,0 +1,69 @@ +getContextKey($key), $default); + } + + /** + * Set a value in the provider context. + */ + protected function setContext(string $key, mixed $value): mixed + { + return CoroutineContext::set($this->getContextKey($key), $value); + } + + /** + * Get or set a value in the provider context. + */ + protected function getOrSetContext(string $key, mixed $value): mixed + { + return CoroutineContext::getOrSet($this->getContextKey($key), $value); + } + + /** + * Forget a value from the provider context. + */ + protected function forgetContext(string $key): void + { + CoroutineContext::forget($this->getContextKey($key)); + } + + /** + * Get the context key for the provider. + */ + protected function getContextKey(string $key): string + { + $namespace = $this->contextNamespace + ??= '__socialite.providers.' . ++self::$nextContextNamespace; + + return $namespace . '.' . $key; + } +} diff --git a/src/socialite/src/Contracts/Factory.php b/src/socialite/src/Contracts/Factory.php index 4bd518dc2..12b39e248 100644 --- a/src/socialite/src/Contracts/Factory.php +++ b/src/socialite/src/Contracts/Factory.php @@ -11,5 +11,5 @@ interface Factory /** * Get a provider implementation. */ - public function driver(UnitEnum|string|null $driver = null): mixed; + public function driver(UnitEnum|string|null $driver = null): Provider; } diff --git a/src/socialite/src/HasProviderContext.php b/src/socialite/src/HasProviderContext.php deleted file mode 100644 index e4da0d481..000000000 --- a/src/socialite/src/HasProviderContext.php +++ /dev/null @@ -1,41 +0,0 @@ -getContextKey($key), $default); - } - - public function setContext(string $key, mixed $value): mixed - { - return CoroutineContext::set($this->getContextKey($key), $value); - } - - public function getOrSetContext(string $key, mixed $value): mixed - { - return CoroutineContext::getOrSet($this->getContextKey($key), $value); - } - - protected function getContextKey(string $key): string - { - $namespace = $this->contextNamespace - ??= '__socialite.providers.' . spl_object_id($this); - - return $namespace . '.' . $key; - } -} diff --git a/src/socialite/src/Socialite.php b/src/socialite/src/Socialite.php index 492be3e9c..214545abd 100644 --- a/src/socialite/src/Socialite.php +++ b/src/socialite/src/Socialite.php @@ -11,37 +11,17 @@ use Hypervel\Support\Facades\Facade; /** - * @method static mixed with(string $driver) - * @method static mixed driver(\UnitEnum|string|null $driver = null) - * @method static mixed buildOAuth2Provider(string $provider, array|null $config) - * @method static array formatConfig(array $config) - * @method static \Hypervel\Socialite\SocialiteManager forgetDrivers() - * @method static \Hypervel\Socialite\SocialiteManager setContainer(\Hypervel\Contracts\Container\Container $container) + * @method static \Hypervel\Socialite\Contracts\Provider with(string $driver) + * @method static \Hypervel\Socialite\Contracts\Provider driver(\UnitEnum|string|null $driver = null) + * @method static \Hypervel\Socialite\Two\AbstractProvider buildOAuth2Provider(string $provider, array|null $config) * @method static string getDefaultDriver() * @method static \Hypervel\Socialite\SocialiteManager extend(string $driver, Closure $callback) * @method static array getDrivers() * @method static \Hypervel\Contracts\Container\Container getContainer() - * @method static \Hypervel\Http\RedirectResponse redirect() - * @method static \Hypervel\Socialite\Two\User user() - * @method static \Hypervel\Socialite\Two\User userFromToken(string $token) - * @method static mixed getAccessTokenResponse(string $code) - * @method static \Hypervel\Socialite\Two\Token refreshToken(string $refreshToken) - * @method static \Hypervel\Socialite\Two\AbstractProvider scopes(array|string $scopes) - * @method static \Hypervel\Socialite\Two\AbstractProvider setScopes(array|string $scopes) - * @method static array getScopes() - * @method static \Hypervel\Socialite\Two\AbstractProvider redirectUrl(string $url) - * @method static \Hypervel\Socialite\Two\AbstractProvider enablePKCE() - * @method static \Hypervel\Socialite\Two\AbstractProvider setConfig(array $config) - * @method static \Hypervel\Socialite\Two\AbstractProvider withConfig(array $config) - * @method static \Hypervel\Socialite\Two\AbstractProvider setHttpClient(\GuzzleHttp\Client $client) - * @method static \Hypervel\Socialite\Two\AbstractProvider setRequest(\Hypervel\Http\Request $request) - * @method static \Hypervel\Socialite\Two\AbstractProvider stateless() - * @method static mixed getContext(string $key, mixed $default = null) - * @method static mixed setContext(string $key, mixed $value) - * @method static mixed getOrSetContext(string $key, mixed $value) + * @method static \Hypervel\Socialite\SocialiteManager setContainer(\Hypervel\Contracts\Container\Container $container) + * @method static \Hypervel\Socialite\SocialiteManager forgetDrivers() * * @see \Hypervel\Socialite\SocialiteManager - * @see \Hypervel\Socialite\Two\AbstractProvider */ class Socialite extends Facade { diff --git a/src/socialite/src/SocialiteManager.php b/src/socialite/src/SocialiteManager.php index b9f43d2fe..1e4acbc0a 100644 --- a/src/socialite/src/SocialiteManager.php +++ b/src/socialite/src/SocialiteManager.php @@ -4,8 +4,9 @@ namespace Hypervel\Socialite; -use Hypervel\Contracts\Container\Container; +use Hypervel\Socialite\Contracts\Provider; use Hypervel\Socialite\Exceptions\DriverMissingConfigurationException; +use Hypervel\Socialite\Two\AbstractProvider as OAuth2Provider; use Hypervel\Socialite\Two\BitbucketProvider; use Hypervel\Socialite\Two\FacebookProvider; use Hypervel\Socialite\Two\GithubProvider; @@ -21,6 +22,7 @@ use Hypervel\Support\Manager; use Hypervel\Support\Str; use InvalidArgumentException; +use SensitiveParameter; use UnitEnum; class SocialiteManager extends Manager implements Contracts\Factory @@ -28,7 +30,7 @@ class SocialiteManager extends Manager implements Contracts\Factory /** * Get a driver instance. */ - public function with(string $driver): mixed + public function with(string $driver): Provider { return $this->driver($driver); } @@ -39,7 +41,7 @@ public function with(string $driver): mixed * Refreshes the request on cached providers so each coroutine * gets the current request, not a stale one from first resolution. */ - public function driver(UnitEnum|string|null $driver = null): mixed + public function driver(UnitEnum|string|null $driver = null): Provider { $provider = parent::driver($driver); @@ -141,6 +143,8 @@ protected function createGitlabDriver(): GitlabProvider ); } + // REMOVED: OAuth 1 and legacy Twitter providers are unsupported; use the X OAuth 2 driver. + /** * Create an instance of the specified driver. */ @@ -195,8 +199,13 @@ protected function createSlackOpenidDriver(): SlackOpenIdProvider /** * Build an OAuth 2 provider instance. + * + * @template TProvider of OAuth2Provider + * + * @param class-string $provider + * @return TProvider */ - public function buildOAuth2Provider(string $provider, ?array $config): mixed + public function buildOAuth2Provider(string $provider, #[SensitiveParameter] ?array $config): OAuth2Provider { $requiredKeys = ['client_id', 'client_secret', 'redirect']; @@ -215,22 +224,10 @@ public function buildOAuth2Provider(string $provider, ?array $config): mixed ))->withConfig($config); } - /** - * Format the server configuration. - */ - public function formatConfig(array $config): array - { - return array_merge([ - 'identifier' => $config['client_id'], - 'secret' => $config['client_secret'], - 'callback_uri' => $this->formatRedirectUrl($config), - ], $config); - } - /** * Format the callback URL, resolving a relative URI if needed. */ - protected function formatRedirectUrl(array $config): string + protected function formatRedirectUrl(#[SensitiveParameter] array $config): string { $redirect = value($config['redirect']); @@ -239,35 +236,6 @@ protected function formatRedirectUrl(array $config): string : $redirect; } - /** - * Forget all of the resolved driver instances. - * - * Boot or tests only. Clears the singleton's driver cache; concurrent - * coroutines may already hold references that next resolution will not - * share. - */ - public function forgetDrivers(): static - { - $this->drivers = []; - - return $this; - } - - /** - * Set the container instance used by the manager. - * - * Tests only. Swaps the singleton's container and config references; - * per-request use races across coroutines and breaks every concurrent - * socialite resolution. - */ - public function setContainer(Container $container): static - { - $this->container = $container; - $this->config = $container->make('config'); - - return $this; - } - /** * Get the default driver name. * diff --git a/src/socialite/src/SocialiteServiceProvider.php b/src/socialite/src/SocialiteServiceProvider.php index bc125cdf2..c83a63d44 100644 --- a/src/socialite/src/SocialiteServiceProvider.php +++ b/src/socialite/src/SocialiteServiceProvider.php @@ -14,6 +14,6 @@ class SocialiteServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->singleton(Factory::class, SocialiteManager::class); + $this->app->alias(SocialiteManager::class, Factory::class); } } diff --git a/src/socialite/src/Testing/SocialiteFake.php b/src/socialite/src/Testing/SocialiteFake.php index 4a9b51cc4..faa69cc42 100644 --- a/src/socialite/src/Testing/SocialiteFake.php +++ b/src/socialite/src/Testing/SocialiteFake.php @@ -6,6 +6,7 @@ use Closure; use Hypervel\Socialite\Contracts\Factory; +use Hypervel\Socialite\Contracts\Provider; use Hypervel\Socialite\Contracts\User as UserContract; use UnitEnum; @@ -31,7 +32,7 @@ public function __construct( /** * Get a provider implementation. */ - public function driver(UnitEnum|string|null $driver = null): mixed + public function driver(UnitEnum|string|null $driver = null): Provider { if ($driver instanceof UnitEnum) { $driver = (string) enum_value($driver); diff --git a/tests/Socialite/AbstractProviderTest.php b/tests/Socialite/AbstractProviderTest.php index d663ec592..2a621255c 100644 --- a/tests/Socialite/AbstractProviderTest.php +++ b/tests/Socialite/AbstractProviderTest.php @@ -9,8 +9,10 @@ use Hypervel\Http\Request; use Hypervel\Tests\Socialite\Fixtures\GenericTestProviderStub; use Hypervel\Tests\TestCase; +use LogicException; use Mockery as m; use Swoole\Coroutine\Channel; +use Throwable; use function Hypervel\Coroutine\parallel; @@ -22,7 +24,7 @@ */ class AbstractProviderTest extends TestCase { - public function testWithConfigSeedsBaselineConfig() + public function testWithConfigSeedsBaselineConfig(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), @@ -36,7 +38,7 @@ public function testWithConfigSeedsBaselineConfig() $this->assertSame('my-realm', $provider->getProviderConfig('realm')); } - public function testSetConfigOverridesPerRequest() + public function testSetConfigOverridesPerRequest(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), @@ -52,7 +54,7 @@ public function testSetConfigOverridesPerRequest() $this->assertSame('tenant-realm', $provider->getProviderConfig('realm')); } - public function testGetConfigReturnsDefaultForMissingKeys() + public function testGetConfigReturnsDefaultForMissingKeys(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), @@ -62,7 +64,19 @@ public function testGetConfigReturnsDefaultForMissingKeys() $this->assertSame('fallback', $provider->getProviderConfig('nonexistent', 'fallback')); } - public function testSetHttpClient() + public function testGetConfigDelegatesNullAndZeroKeysToArr(): void + { + $provider = new GenericTestProviderStub(m::mock(Request::class)); + $provider->withConfig([ + 0 => 'zero', + 'realm' => 'default', + ]); + + $this->assertSame('zero', $provider->getProviderConfig('0')); + $this->assertSame([0 => 'zero', 'realm' => 'default'], $provider->getProviderConfig()); + } + + public function testSetHttpClient(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), @@ -74,7 +88,7 @@ public function testSetHttpClient() $this->assertSame($client, $provider->getProviderHttpClient()); } - public function testStatelessToggle() + public function testStatelessToggle(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), @@ -87,7 +101,7 @@ public function testStatelessToggle() $this->assertFalse($provider->providerUsesState()); } - public function testSetRequest() + public function testSetRequest(): void { $originalRequest = m::mock(Request::class); $newRequest = m::mock(Request::class); @@ -98,7 +112,7 @@ public function testSetRequest() $this->assertSame($newRequest, $provider->getProviderRequest()); } - public function testSetRequestIsIsolatedPerCoroutine() + public function testSetRequestIsIsolatedPerCoroutine(): void { $provider = new GenericTestProviderStub( Request::create('/baseline'), @@ -125,7 +139,55 @@ function () use ($provider): string { $this->assertSame('/tenant-b', $pathB); } - public function testBaselineConfigSurvivesAcrossCoroutines() + public function testRequestMustBeSeededInEachCoroutine(): void + { + $provider = new GenericTestProviderStub(Request::create('/baseline')); + $channel = new Channel(1); + + Coroutine::create(function () use ($provider, $channel): void { + try { + $provider->getProviderRequest(); + } catch (Throwable $exception) { + $channel->push($exception); + } + }); + + $exception = $channel->pop(1.0); + + $this->assertInstanceOf(LogicException::class, $exception); + $this->assertSame( + 'No request is available for this provider. Resolve it through Socialite::driver() or call setRequest().', + $exception->getMessage() + ); + } + + public function testRecycledObjectIdsCannotReuseProviderContext(): void + { + $request = Request::create('/'); + $provider = new GenericTestProviderStub($request); + $objectId = spl_object_id($provider); + $provider->rememberProviderMarker('tenant-a'); + + unset($provider); + + $replacement = null; + + for ($attempt = 0; $attempt < 1000; ++$attempt) { + $candidate = new GenericTestProviderStub($request); + + if (spl_object_id($candidate) === $objectId) { + $replacement = $candidate; + break; + } + + unset($candidate); + } + + $this->assertNotNull($replacement); + $this->assertNull($replacement->getProviderMarker()); + } + + public function testBaselineConfigSurvivesAcrossCoroutines(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), @@ -147,7 +209,7 @@ public function testBaselineConfigSurvivesAcrossCoroutines() $this->assertSame('https://idp.example.com', $childValue); } - public function testSetConfigIsIsolatedPerCoroutine() + public function testSetConfigIsIsolatedPerCoroutine(): void { $provider = new GenericTestProviderStub( m::mock(Request::class), diff --git a/tests/Socialite/Fixtures/GenericTestProviderStub.php b/tests/Socialite/Fixtures/GenericTestProviderStub.php index 3a36c5350..bd2607f1b 100644 --- a/tests/Socialite/Fixtures/GenericTestProviderStub.php +++ b/tests/Socialite/Fixtures/GenericTestProviderStub.php @@ -47,4 +47,20 @@ public function getProviderRequest(): Request { return $this->getRequest(); } + + /** + * Store a marker in the provider context. + */ + public function rememberProviderMarker(string $marker): void + { + $this->setContext('marker', $marker); + } + + /** + * Get the marker from the provider context. + */ + public function getProviderMarker(): ?string + { + return $this->getContext('marker'); + } } diff --git a/tests/Socialite/SocialiteManagerTest.php b/tests/Socialite/SocialiteManagerTest.php index a89bcc00f..209b095b7 100644 --- a/tests/Socialite/SocialiteManagerTest.php +++ b/tests/Socialite/SocialiteManagerTest.php @@ -9,12 +9,14 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Coroutine\Coroutine; use Hypervel\Http\Request; +use Hypervel\Socialite\Contracts\Factory; use Hypervel\Socialite\Exceptions\DriverMissingConfigurationException; use Hypervel\Socialite\SocialiteManager; +use Hypervel\Socialite\SocialiteServiceProvider; use Hypervel\Socialite\Two\GithubProvider; use Hypervel\Socialite\Two\GitlabProvider; use Hypervel\Testbench\TestCase; -use Hypervel\Tests\Socialite\Fixtures\GenericTestProviderStub; +use Hypervel\Tests\Socialite\Fixtures\OAuthTwoTestProviderStub; use ReflectionProperty; use Swoole\Coroutine\Channel; @@ -22,6 +24,11 @@ class SocialiteManagerTest extends TestCase { + protected function getPackageProviders($app): array + { + return [SocialiteServiceProvider::class]; + } + public function setUp(): void { parent::setUp(); @@ -34,7 +41,7 @@ public function setUp(): void ]); } - public function testItCanInstantiateTheGithubDriver() + public function testItCanInstantiateTheGithubDriver(): void { $factory = $this->app->make(SocialiteManager::class); @@ -43,7 +50,24 @@ public function testItCanInstantiateTheGithubDriver() $this->assertInstanceOf(GithubProvider::class, $provider); } - public function testGitlabDriverUsesConfiguredHost() + public function testFactoryAndConcreteManagerShareOneDriverRegistry(): void + { + $factory = $this->app->make(Factory::class); + $manager = $this->app->make(SocialiteManager::class); + + $this->assertSame($manager, $factory); + + $manager->extend('custom', static fn (Container $container) => new OAuthTwoTestProviderStub( + $container->make('request'), + 'client_id', + 'client_secret', + 'redirect' + )); + + $this->assertSame($manager->driver('custom'), $factory->driver('custom')); + } + + public function testGitlabDriverUsesConfiguredHost(): void { $this->app->make('config') ->set('services.gitlab', [ @@ -63,7 +87,7 @@ public function testGitlabDriverUsesConfiguredHost() ); } - public function testGitlabDriverFallsBackToDefaultHostWhenHostIsNull() + public function testGitlabDriverFallsBackToDefaultHostWhenHostIsNull(): void { $this->app->make('config') ->set('services.gitlab', [ @@ -83,7 +107,7 @@ public function testGitlabDriverFallsBackToDefaultHostWhenHostIsNull() ); } - public function testGitlabHostOverrideIsIsolatedPerCoroutine() + public function testGitlabHostOverrideIsIsolatedPerCoroutine(): void { $provider = new GitlabProvider( Request::create('/'), @@ -115,7 +139,10 @@ function () use ($provider): string { $this->assertStringStartsWith('https://gitlab-b.example.com/oauth/authorize?', $urlB); } - public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArray() + // REMOVED: Laravel Socialite's OAuth 1 and legacy Twitter manager tests do not apply; + // Hypervel exposes X through OAuth 2 as the "x" driver. + + public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArray(): void { $factory = $this->app->make(SocialiteManager::class); $this->app->make('config') @@ -129,14 +156,14 @@ public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArray() $this->assertSame(['user:email', 'read:user'], $provider->getScopes()); } - public function testItCanInstantiateTheGithubDriverWithScopesWithoutArrayFromConfig() + public function testItCanInstantiateTheGithubDriverWithScopesWithoutArrayFromConfig(): void { $factory = $this->app->make(SocialiteManager::class); $provider = $factory->driver('github'); $this->assertSame(['user:email'], $provider->getScopes()); } - public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayMergedByProgrammaticScopesUsingScopesMethod() + public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayMergedByProgrammaticScopesUsingScopesMethod(): void { $factory = $this->app->make(SocialiteManager::class); $this->app->make('config') @@ -150,7 +177,7 @@ public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayMerg $this->assertSame(['user:email', 'read:user'], $provider->getScopes()); } - public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayOverwrittenByProgrammaticScopesUsingSetScopesMethod() + public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayOverwrittenByProgrammaticScopesUsingSetScopesMethod(): void { $factory = $this->app->make(SocialiteManager::class); $this->app->make('config') @@ -164,7 +191,7 @@ public function testItCanInstantiateTheGithubDriverWithScopesFromConfigArrayOver $this->assertSame(['read:user'], $provider->getScopes()); } - public function testItThrowsExceptionWhenClientSecretIsMissing() + public function testItThrowsExceptionWhenClientSecretIsMissing(): void { $this->expectException(DriverMissingConfigurationException::class); $this->expectExceptionMessage('Missing required configuration keys [client_secret] for [Hypervel\Socialite\Two\GithubProvider] OAuth provider.'); @@ -180,7 +207,7 @@ public function testItThrowsExceptionWhenClientSecretIsMissing() $factory->driver('github'); } - public function testItThrowsExceptionWhenClientIdIsMissing() + public function testItThrowsExceptionWhenClientIdIsMissing(): void { $this->expectException(DriverMissingConfigurationException::class); $this->expectExceptionMessage('Missing required configuration keys [client_id] for [Hypervel\Socialite\Two\GithubProvider] OAuth provider.'); @@ -196,7 +223,7 @@ public function testItThrowsExceptionWhenClientIdIsMissing() $factory->driver('github'); } - public function testItThrowsExceptionWhenRedirectIsMissing() + public function testItThrowsExceptionWhenRedirectIsMissing(): void { $this->expectException(DriverMissingConfigurationException::class); $this->expectExceptionMessage('Missing required configuration keys [redirect] for [Hypervel\Socialite\Two\GithubProvider] OAuth provider.'); @@ -212,7 +239,7 @@ public function testItThrowsExceptionWhenRedirectIsMissing() $factory->driver('github'); } - public function testItThrowsExceptionWhenConfigurationIsCompletelyMissing() + public function testItThrowsExceptionWhenConfigurationIsCompletelyMissing(): void { $this->expectException(DriverMissingConfigurationException::class); $this->expectExceptionMessage('Missing required configuration keys [client_id, client_secret, redirect] for [Hypervel\Socialite\Two\GithubProvider] OAuth provider.'); @@ -225,7 +252,7 @@ public function testItThrowsExceptionWhenConfigurationIsCompletelyMissing() $factory->driver('github'); } - public function testSetConfigOverridesDriverCredentials() + public function testSetConfigOverridesDriverCredentials(): void { $factory = $this->app->make(SocialiteManager::class); @@ -244,7 +271,7 @@ public function testSetConfigOverridesDriverCredentials() $this->assertStringNotContainsString('github-client-id', $response->getTargetUrl()); } - public function testSameProviderClassWithDifferentDriversDoesNotCollide() + public function testSameProviderClassWithDifferentDriversDoesNotCollide(): void { $this->app->make('config') ->set('services.github_a', [ @@ -283,7 +310,7 @@ public function testSameProviderClassWithDifferentDriversDoesNotCollide() $this->assertStringContainsString('client_id=tenant_b', $driverB->redirect()->getTargetUrl()); } - public function testConfigScopesSurviveAcrossCoroutines() + public function testConfigScopesSurviveAcrossCoroutines(): void { $this->app->make('config') ->set('services.github', [ @@ -310,7 +337,7 @@ public function testConfigScopesSurviveAcrossCoroutines() $this->assertSame(['user:email', 'read:user'], $childScopes); } - public function testGenericProviderGetsRequestRefreshed() + public function testCachedProviderGetsRequestRefreshed(): void { $firstRequest = Request::create('/first'); $secondRequest = Request::create('/second'); @@ -319,8 +346,11 @@ public function testGenericProviderGetsRequestRefreshed() $factory = $this->app->make(SocialiteManager::class); - $factory->extend('generic', static fn (Container $container) => new GenericTestProviderStub( - $container->make('request') + $factory->extend('generic', static fn (Container $container) => new OAuthTwoTestProviderStub( + $container->make('request'), + 'client_id', + 'client_secret', + 'redirect' )); $provider = $factory->driver('generic'); @@ -332,7 +362,7 @@ public function testGenericProviderGetsRequestRefreshed() $this->assertSame($secondRequest, $provider->getProviderRequest()); } - public function testSetContainerRefreshesConfig() + public function testSetContainerRefreshesConfig(): void { $factory = $this->app->make(SocialiteManager::class); From e147c7a7edd3f9bc3af7565f938153ab43117c25 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:37:17 +0000 Subject: [PATCH 05/14] socialite: centralize OAuth response handling Parse token responses through protected access-token, refresh-token, expiry, scope, and whole-response seams shared by login and refresh flows. Preserve unrotated refresh tokens, accept bounded protocol digit strings, publish complete token responses on returned users, and cache authenticated users only after mapping and decoration succeed. Restore the current OAuth 2 user fake and cover parser overrides, transactional memoization, direct token lookup, response publication, and fake-manager separation. --- src/socialite/src/Two/AbstractProvider.php | 134 +++++++++++--- src/socialite/src/Two/Token.php | 8 +- src/socialite/src/Two/TwitchProvider.php | 36 +--- src/socialite/src/Two/User.php | 46 ++++- .../Fixtures/OAuthTwoTestProviderStub.php | 41 ++++- tests/Socialite/OAuthTwoTest.php | 173 ++++++++++++++++-- tests/Socialite/SocialiteFakeTest.php | 49 ++++- 7 files changed, 393 insertions(+), 94 deletions(-) diff --git a/src/socialite/src/Two/AbstractProvider.php b/src/socialite/src/Two/AbstractProvider.php index c43c083b0..eafa0a5b9 100644 --- a/src/socialite/src/Two/AbstractProvider.php +++ b/src/socialite/src/Two/AbstractProvider.php @@ -9,8 +9,10 @@ use Hypervel\Http\Request; use Hypervel\Socialite\AbstractProvider as BaseProvider; use Hypervel\Socialite\Contracts\Provider as ProviderContract; +use Hypervel\Socialite\Two\Exceptions\InvalidAudienceException; use Hypervel\Support\Arr; use Hypervel\Support\Str; +use SensitiveParameter; abstract class AbstractProvider extends BaseProvider implements ProviderContract { @@ -48,6 +50,7 @@ abstract class AbstractProvider extends BaseProvider implements ProviderContract public function __construct( Request $request, protected string $clientId, + #[SensitiveParameter] protected string $clientSecret, protected string $redirectUrl, array $guzzle = [] @@ -68,7 +71,7 @@ abstract protected function getTokenUrl(): string; /** * Get the raw user for the given access token. */ - abstract protected function getUserByToken(string $token): mixed; + abstract protected function getUserByToken(#[SensitiveParameter] string $token): array; /** * Map the raw user array to a Socialite User instance. @@ -133,6 +136,9 @@ protected function formatScopes(array $scopes, string $scopeSeparator): string return implode($scopeSeparator, $scopes); } + /** + * Get the User instance for the authenticated user. + */ public function user(): User { if ($user = $this->getUser()) { @@ -145,9 +151,7 @@ public function user(): User $response = $this->getAccessTokenResponse($this->getCode()); - $user = $this->getUserByToken(Arr::get($response, 'access_token')); - - return $this->userInstance($response, $user); + return $this->userInstance($response, $this->getUserByTokenResponse($response)); } /** @@ -171,26 +175,36 @@ protected function setUser(User $user): static /** * Create a user instance from the given data. */ - protected function userInstance(array $response, array $user): User + protected function userInstance(#[SensitiveParameter] array $response, array $user): User { - $this->setUser( - $this->mapUserToObject($user) - ); + $instance = $this->mapUserToObject($user); + + $instance->setToken($this->parseAccessToken($response)) + ->setRefreshToken($this->parseRefreshToken($response)) + ->setExpiresIn($this->parseExpiresIn($response)) + ->setApprovedScopes($this->parseApprovedScopes($response)) + ->setAccessTokenResponseBody($response); + + $this->setUser($instance); - return $this->getUser()->setToken(Arr::get($response, 'access_token')) - ->setRefreshToken(Arr::get($response, 'refresh_token')) - ->setExpiresIn(Arr::get($response, 'expires_in')) - ->setApprovedScopes(explode($this->scopeSeparator, Arr::get($response, 'scope', ''))); + return $instance; } /** - * Get a Social User instance from a known access token. + * Get the raw user from the token response. */ - public function userFromToken(string $token): User + protected function getUserByTokenResponse(#[SensitiveParameter] array $response): array { - $user = $this->mapUserToObject($this->getUserByToken($token)); + return $this->getUserByToken($this->parseAccessToken($response)); + } - return $user->setToken($token); + /** + * Get a Social User instance from a known access token. + */ + public function userFromToken(#[SensitiveParameter] string $token): User + { + return $this->mapUserToObject($this->getUserByToken($token)) + ->setToken($token); } /** @@ -210,7 +224,7 @@ protected function hasInvalidState(): bool /** * Get the access token response for the given code. */ - public function getAccessTokenResponse(string $code): mixed + public function getAccessTokenResponse(#[SensitiveParameter] string $code): array { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::HEADERS => $this->getTokenHeaders($code), @@ -223,7 +237,7 @@ public function getAccessTokenResponse(string $code): mixed /** * Get the headers for the access token request. */ - protected function getTokenHeaders(string $code): array + protected function getTokenHeaders(#[SensitiveParameter] string $code): array { return ['Accept' => 'application/json']; } @@ -231,7 +245,7 @@ protected function getTokenHeaders(string $code): array /** * Get the POST fields for the token request. */ - protected function getTokenFields(string $code): array + protected function getTokenFields(#[SensitiveParameter] string $code): array { $fields = [ 'grant_type' => 'authorization_code', @@ -251,22 +265,22 @@ protected function getTokenFields(string $code): array /** * Refresh a user's access token with a refresh token. */ - public function refreshToken(string $refreshToken): Token + public function refreshToken(#[SensitiveParameter] string $refreshToken): Token { $response = $this->getRefreshTokenResponse($refreshToken); return new Token( - Arr::get($response, 'access_token'), - Arr::get($response, 'refresh_token'), - Arr::get($response, 'expires_in'), - explode($this->scopeSeparator, Arr::get($response, 'scope', '')) + $this->parseAccessToken($response), + $this->parseRefreshToken($response) ?? $refreshToken, + $this->parseExpiresIn($response), + $this->parseApprovedScopes($response) ); } /** * Get the refresh token response for the given refresh token. */ - protected function getRefreshTokenResponse(string $refreshToken): mixed + protected function getRefreshTokenResponse(#[SensitiveParameter] string $refreshToken): array { return json_decode((string) $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::HEADERS => ['Accept' => 'application/json'], @@ -279,6 +293,55 @@ protected function getRefreshTokenResponse(string $refreshToken): mixed ])->getBody(), true); } + /** + * Parse the access token from a token response. + */ + protected function parseAccessToken(#[SensitiveParameter] array $response): string + { + return Arr::get($response, 'access_token'); + } + + /** + * Parse the refresh token from a token response. + */ + protected function parseRefreshToken(#[SensitiveParameter] array $response): ?string + { + return Arr::get($response, 'refresh_token'); + } + + /** + * Parse the expiration period from a token response. + */ + protected function parseExpiresIn(#[SensitiveParameter] array $response): ?int + { + $expiresIn = Arr::get($response, 'expires_in'); + + if (is_int($expiresIn)) { + return $expiresIn >= 0 ? $expiresIn : null; + } + + if (! is_string($expiresIn) || ! ctype_digit($expiresIn)) { + return null; + } + + $normalized = ltrim($expiresIn, '0'); + $parsed = filter_var($normalized === '' ? '0' : $normalized, FILTER_VALIDATE_INT); + + return $parsed === false ? null : $parsed; + } + + /** + * Parse the approved scopes from a token response. + */ + protected function parseApprovedScopes(#[SensitiveParameter] array $response): array + { + $scopes = Arr::get($response, 'scope'); + + return is_array($scopes) + ? $scopes + : (is_string($scopes) && $scopes !== '' ? explode($this->scopeSeparator, $scopes) : []); + } + /** * Get the code from the request. */ @@ -356,6 +419,25 @@ protected function getClientSecret(): string return $this->getContext('clientSecret', $this->clientSecret); } + /** + * Validate the token audience for the provider. + */ + protected function validateAudience(mixed $audience): void + { + $audiences = is_array($audience) ? $audience : [$audience]; + $trusted = [$this->getClientId(), ...Arr::wrap($this->getConfig('trusted_audiences', []))]; + + if (! in_array($this->getClientId(), $audiences, true)) { + throw new InvalidAudienceException; + } + + foreach ($audiences as $candidate) { + if (! is_string($candidate) || ! in_array($candidate, $trusted, true)) { + throw new InvalidAudienceException; + } + } + } + /** * Determine if the provider uses PKCE. */ @@ -406,7 +488,7 @@ protected function getCodeChallengeMethod(): string * Extends the base setConfig to also handle OAuth2-specific credential * keys (client_id, client_secret, redirect) in coroutine context. */ - public function setConfig(array $config): static + public function setConfig(#[SensitiveParameter] array $config): static { if (isset($config['client_id'])) { $this->setContext('clientId', $config['client_id']); diff --git a/src/socialite/src/Two/Token.php b/src/socialite/src/Two/Token.php index 0d9927b2d..53e54386d 100644 --- a/src/socialite/src/Two/Token.php +++ b/src/socialite/src/Two/Token.php @@ -4,6 +4,8 @@ namespace Hypervel\Socialite\Two; +use SensitiveParameter; + class Token { /** @@ -11,13 +13,15 @@ class Token * * @param string $token the user's access token * @param string $refreshToken the refresh token that can be exchanged for a new access token - * @param int $expiresIn the number of seconds the access token is valid for + * @param null|int $expiresIn the number of seconds the access token is valid for * @param array $approvedScopes The scopes the user authorized. The approved scopes may be a subset of the requested scopes. */ public function __construct( + #[SensitiveParameter] public string $token, + #[SensitiveParameter] public string $refreshToken, - public int $expiresIn, + public ?int $expiresIn, public array $approvedScopes ) { } diff --git a/src/socialite/src/Two/TwitchProvider.php b/src/socialite/src/Two/TwitchProvider.php index b0b7bdf99..3e16fa3b6 100644 --- a/src/socialite/src/Two/TwitchProvider.php +++ b/src/socialite/src/Two/TwitchProvider.php @@ -6,6 +6,7 @@ use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class TwitchProvider extends AbstractProvider implements ProviderInterface { @@ -29,7 +30,7 @@ protected function getTokenUrl(): string return 'https://id.twitch.tv/oauth2/token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get( 'https://api.twitch.tv/helix/users', @@ -45,27 +46,6 @@ protected function getUserByToken(string $token): array return json_decode((string) $response->getBody(), true); } - /** - * Create a user instance from the given data. - */ - protected function userInstance(array $response, array $user): User - { - $this->setUser( - $this->mapUserToObject($user) - ); - - $scopes = Arr::get($response, 'scope', []); - - if (! is_array($scopes)) { - $scopes = explode($this->scopeSeparator, $scopes); - } - - return $this->getUser()->setToken(Arr::get($response, 'access_token')) - ->setRefreshToken(Arr::get($response, 'refresh_token')) - ->setExpiresIn(Arr::get($response, 'expires_in')) - ->setApprovedScopes($scopes); - } - protected function mapUserToObject(array $user): User { $user = $user['data']['0']; @@ -78,16 +58,4 @@ protected function mapUserToObject(array $user): User 'avatar' => $user['profile_image_url'], ]); } - - public function refreshToken(string $refreshToken): Token - { - $response = $this->getRefreshTokenResponse($refreshToken); - - return new Token( - Arr::get($response, 'access_token'), - Arr::get($response, 'refresh_token'), - Arr::get($response, 'expires_in'), - Arr::get($response, 'scope', []) - ); - } } diff --git a/src/socialite/src/Two/User.php b/src/socialite/src/Two/User.php index ed6ff8fac..40a6ab361 100644 --- a/src/socialite/src/Two/User.php +++ b/src/socialite/src/Two/User.php @@ -5,6 +5,7 @@ namespace Hypervel\Socialite\Two; use Hypervel\Socialite\AbstractUser; +use SensitiveParameter; class User extends AbstractUser { @@ -28,10 +29,41 @@ class User extends AbstractUser */ public array $approvedScopes = []; + /** + * The complete access token response. + */ + public array $accessTokenResponseBody = []; + + /** + * Create a fake OAuth 2 user instance. + */ + public static function fake(#[SensitiveParameter] array $attributes = []): self + { + $attributes = array_merge([ + 'id' => '123456789', + 'nickname' => 'testuser', + 'name' => 'Test User', + 'email' => 'test@example.com', + 'avatar' => 'https://example.com/avatar.jpg', + 'token' => 'fake-token', + 'refreshToken' => 'fake-refresh-token', + 'expiresIn' => 3600, + 'approvedScopes' => [], + 'accessTokenResponseBody' => [], + ], $attributes); + + return (new self)->setRaw($attributes)->map($attributes) + ->setToken($attributes['token']) + ->setRefreshToken($attributes['refreshToken']) + ->setExpiresIn($attributes['expiresIn']) + ->setApprovedScopes($attributes['approvedScopes']) + ->setAccessTokenResponseBody($attributes['accessTokenResponseBody']); + } + /** * Set the token on the user. */ - public function setToken(?string $token): static + public function setToken(#[SensitiveParameter] ?string $token): static { $this->token = $token; @@ -41,7 +73,7 @@ public function setToken(?string $token): static /** * Set the refresh token required to obtain a new access token. */ - public function setRefreshToken(?string $refreshToken): static + public function setRefreshToken(#[SensitiveParameter] ?string $refreshToken): static { $this->refreshToken = $refreshToken; @@ -67,4 +99,14 @@ public function setApprovedScopes(array $approvedScopes): static return $this; } + + /** + * Set the complete access token response on the user. + */ + public function setAccessTokenResponseBody(#[SensitiveParameter] array $accessTokenResponseBody): static + { + $this->accessTokenResponseBody = $accessTokenResponseBody; + + return $this; + } } diff --git a/tests/Socialite/Fixtures/OAuthTwoTestProviderStub.php b/tests/Socialite/Fixtures/OAuthTwoTestProviderStub.php index 8b2dd975f..fb8e39b5c 100644 --- a/tests/Socialite/Fixtures/OAuthTwoTestProviderStub.php +++ b/tests/Socialite/Fixtures/OAuthTwoTestProviderStub.php @@ -5,16 +5,15 @@ namespace Hypervel\Tests\Socialite\Fixtures; use GuzzleHttp\Client; +use Hypervel\Http\Request; use Hypervel\Socialite\Two\AbstractProvider; use Hypervel\Socialite\Two\User; use Mockery as m; +use SensitiveParameter; class OAuthTwoTestProviderStub extends AbstractProvider { - /** - * @var \GuzzleHttp\Client|\Mockery\MockInterface - */ - public $http; + public ?Client $http = null; protected function getAuthUrl(?string $state): string { @@ -26,7 +25,7 @@ protected function getTokenUrl(): string return 'http://token.url'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { return ['id' => 'foo']; } @@ -36,10 +35,38 @@ protected function mapUserToObject(array $user): User return (new User)->map(['id' => $user['id']]); } + public function parseProviderAccessToken(#[SensitiveParameter] array $response): string + { + return $this->parseAccessToken($response); + } + + public function parseProviderRefreshToken(#[SensitiveParameter] array $response): ?string + { + return $this->parseRefreshToken($response); + } + + public function parseProviderExpiresIn(#[SensitiveParameter] array $response): ?int + { + return $this->parseExpiresIn($response); + } + + public function parseProviderApprovedScopes(#[SensitiveParameter] array $response): array + { + return $this->parseApprovedScopes($response); + } + + public function getProviderUser(): ?User + { + return $this->getUser(); + } + + public function getProviderRequest(): Request + { + return $this->getRequest(); + } + /** * Get a fresh instance of the Guzzle HTTP client. - * - * @return \GuzzleHttp\Client|\Mockery\MockInterface */ protected function getHttpClient(): Client { diff --git a/tests/Socialite/OAuthTwoTest.php b/tests/Socialite/OAuthTwoTest.php index 713607016..1db2f2408 100644 --- a/tests/Socialite/OAuthTwoTest.php +++ b/tests/Socialite/OAuthTwoTest.php @@ -20,13 +20,16 @@ use Hypervel\Tests\Socialite\Fixtures\OAuthTwoWithPKCETestProviderStub; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use SensitiveParameter; use Swoole\Coroutine\Channel; +use TypeError; class OAuthTwoTest extends TestCase { - public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE() + public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -61,7 +64,7 @@ public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE() ); } - public function testRedirectGeneratesTheProperRedirectResponseWithPKCE() + public function testRedirectGeneratesTheProperRedirectResponseWithPKCE(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -106,7 +109,7 @@ public function testRedirectGeneratesTheProperRedirectResponseWithPKCE() ); } - public function testTokenRequestIncludesPKCECodeVerifier() + public function testTokenRequestIncludesPKCECodeVerifier(): void { $request = m::mock(Request::class); $request->shouldReceive('has') @@ -149,7 +152,7 @@ public function testTokenRequestIncludesPKCECodeVerifier() $this->assertSame($user->id, $provider->user()->id); } - public function testUserReturnsAUserInstanceForTheAuthenticatedRequest() + public function testUserReturnsAUserInstanceForTheAuthenticatedRequest(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -187,10 +190,15 @@ public function testUserReturnsAUserInstanceForTheAuthenticatedRequest() $this->assertSame('access_token', $user->token); $this->assertSame('refresh_token', $user->refreshToken); $this->assertSame(3600, $user->expiresIn); + $this->assertSame([ + 'access_token' => 'access_token', + 'refresh_token' => 'refresh_token', + 'expires_in' => 3600, + ], $user->accessTokenResponseBody); $this->assertSame($user->id, $provider->user()->id); } - public function testUserReturnsAUserInstanceForTheAuthenticatedFacebookRequest() + public function testUserReturnsAUserInstanceForTheAuthenticatedFacebookRequest(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -229,7 +237,7 @@ public function testUserReturnsAUserInstanceForTheAuthenticatedFacebookRequest() $this->assertSame($user->id, $provider->user()->id); } - public function testExceptionIsThrownIfStateIsInvalid() + public function testExceptionIsThrownIfStateIsInvalid(): void { $this->expectException(InvalidStateException::class); @@ -253,7 +261,7 @@ public function testExceptionIsThrownIfStateIsInvalid() $provider->user(); } - public function testExceptionIsThrownIfStateIsNotSet() + public function testExceptionIsThrownIfStateIsNotSet(): void { $this->expectException(InvalidStateException::class); @@ -270,7 +278,7 @@ public function testExceptionIsThrownIfStateIsNotSet() $provider->user(); } - public function testUserRefreshesToken() + public function testUserRefreshesToken(): void { $request = m::mock(Request::class); $provider = new OAuthTwoTestProviderStub( @@ -296,7 +304,7 @@ public function testUserRefreshesToken() $this->assertSame(['scope1', 'scope2'], $token->approvedScopes); } - public function testUserRefreshesGoogleToken() + public function testUserRefreshesGoogleToken(): void { $request = m::mock(Request::class); $provider = new GoogleTestProviderStub( @@ -322,7 +330,132 @@ public function testUserRefreshesGoogleToken() $this->assertSame(['scope1', 'scope2'], $token->approvedScopes); } - public function testSetConfigOverridesCredentialsInRedirect() + public function testTokenResponseParsersNormalizeProviderValues(): void + { + $provider = new OAuthTwoTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + + $this->assertSame('access-token', $provider->parseProviderAccessToken(['access_token' => 'access-token'])); + $this->assertNull($provider->parseProviderRefreshToken([])); + $this->assertSame('refresh-token', $provider->parseProviderRefreshToken(['refresh_token' => 'refresh-token'])); + $this->assertSame([], $provider->parseProviderApprovedScopes([])); + $this->assertSame([], $provider->parseProviderApprovedScopes(['scope' => ''])); + $this->assertSame(['read', 'write'], $provider->parseProviderApprovedScopes(['scope' => 'read,write'])); + $this->assertSame(['read', 'write'], $provider->parseProviderApprovedScopes(['scope' => ['read', 'write']])); + } + + #[DataProvider('expiresInProvider')] + public function testExpiresInParserAcceptsNonNegativeIntegers(array $response, ?int $expected): void + { + $provider = new OAuthTwoTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + + $this->assertSame($expected, $provider->parseProviderExpiresIn($response)); + } + + public static function expiresInProvider(): array + { + return [ + 'missing' => [[], null], + 'zero integer' => [['expires_in' => 0], 0], + 'positive integer' => [['expires_in' => 600], 600], + 'negative integer' => [['expires_in' => -1], null], + 'zero string' => [['expires_in' => '0'], 0], + 'positive digit string' => [['expires_in' => '600'], 600], + 'zero-padded string' => [['expires_in' => '0600'], 600], + 'negative string' => [['expires_in' => '-1'], null], + 'decimal string' => [['expires_in' => '1.5'], null], + 'integer maximum' => [['expires_in' => (string) PHP_INT_MAX], PHP_INT_MAX], + 'integer overflow' => [['expires_in' => (string) PHP_INT_MAX . '0'], null], + 'float' => [['expires_in' => 600.0], null], + ]; + } + + public function testMissingAccessTokenFailsAtTheParserBoundary(): void + { + $provider = new OAuthTwoTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + + $this->expectException(TypeError::class); + + $provider->parseProviderAccessToken([]); + } + + public function testWholeTokenResponseCanMapAndPopulateTheUser(): void + { + $request = m::mock(Request::class); + $request->expects('input')->with('code')->andReturn('code'); + + $provider = new OAuthTwoWholeResponseTestProviderStub( + $request, + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->stateless(); + $provider->http = m::mock(Client::class); + $provider->http->expects('post')->andReturn($response = m::mock(ResponseInterface::class)); + $stream = m::mock(StreamInterface::class); + $stream->allows('__toString')->andReturn(json_encode([ + 'access_token' => 'access-token', + 'profile_id' => 'response-user', + ])); + $response->expects('getBody')->andReturn($stream); + + $user = $provider->user(); + + $this->assertSame('response-user', $user->id); + $this->assertSame([ + 'access_token' => 'access-token', + 'profile_id' => 'response-user', + ], $user->accessTokenResponseBody); + + $userFromToken = $provider->userFromToken('known-token'); + + $this->assertSame([], $userFromToken->accessTokenResponseBody); + } + + public function testFailedUserDecorationDoesNotCachePartialUser(): void + { + $request = m::mock(Request::class); + $request->expects('input')->with('code')->andReturn('code'); + + $provider = new OAuthTwoWholeResponseTestProviderStub( + $request, + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->stateless(); + $provider->http = m::mock(Client::class); + $provider->http->expects('post')->andReturn($response = m::mock(ResponseInterface::class)); + $stream = m::mock(StreamInterface::class); + $stream->allows('__toString')->andReturn('{"profile_id":"response-user"}'); + $response->expects('getBody')->andReturn($stream); + + try { + $provider->user(); + $this->fail('Expected token parsing to fail.'); + } catch (TypeError) { + $this->addToAssertionCount(1); + } + + $this->assertNull($provider->getProviderUser()); + } + + public function testSetConfigOverridesCredentialsInRedirect(): void { $request = m::mock(Request::class); @@ -348,7 +481,7 @@ public function testSetConfigOverridesCredentialsInRedirect() $this->assertStringNotContainsString('original_redirect', $response->getTargetUrl()); } - public function testSetConfigOverridesCredentialsInTokenRequest() + public function testSetConfigOverridesCredentialsInTokenRequest(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -398,7 +531,7 @@ public function testSetConfigOverridesCredentialsInTokenRequest() $this->assertSame('foo', $user->id); } - public function testSetConfigOverridesCredentialsInRefreshToken() + public function testSetConfigOverridesCredentialsInRefreshToken(): void { $request = m::mock(Request::class); @@ -432,7 +565,7 @@ public function testSetConfigOverridesCredentialsInRefreshToken() $this->assertSame('access_token', $token->token); } - public function testSetConfigPartialOverridePreservesDefaults() + public function testSetConfigPartialOverridePreservesDefaults(): void { $request = m::mock(Request::class); @@ -453,7 +586,7 @@ public function testSetConfigPartialOverridePreservesDefaults() $this->assertStringContainsString('redirect_uri=original_redirect', $response->getTargetUrl()); } - public function testGetConfigReturnsAdditionalKeys() + public function testGetConfigReturnsAdditionalKeys(): void { $request = m::mock(Request::class); @@ -475,7 +608,7 @@ public function testGetConfigReturnsAdditionalKeys() $this->assertNull($provider->getProviderConfig('missing_key')); } - public function testWithConfigBaselineSurvivesAcrossCoroutines() + public function testWithConfigBaselineSurvivesAcrossCoroutines(): void { $request = m::mock(Request::class); @@ -516,7 +649,7 @@ public function testWithConfigBaselineSurvivesAcrossCoroutines() $this->assertSame('https://auth.example.com', $provider->getProviderConfig('base_url')); } - public function testSetConfigIsIsolatedPerCoroutine() + public function testSetConfigIsIsolatedPerCoroutine(): void { $request = m::mock(Request::class); @@ -560,3 +693,11 @@ public function testSetConfigIsIsolatedPerCoroutine() $this->assertStringContainsString('client_id=base_id', $fallbackUrl); } } + +class OAuthTwoWholeResponseTestProviderStub extends OAuthTwoTestProviderStub +{ + protected function getUserByTokenResponse(#[SensitiveParameter] array $response): array + { + return ['id' => $response['profile_id']]; + } +} diff --git a/tests/Socialite/SocialiteFakeTest.php b/tests/Socialite/SocialiteFakeTest.php index 277bc43c8..d9efb102b 100644 --- a/tests/Socialite/SocialiteFakeTest.php +++ b/tests/Socialite/SocialiteFakeTest.php @@ -6,6 +6,7 @@ use Hypervel\Socialite\Contracts\Factory; use Hypervel\Socialite\Socialite; +use Hypervel\Socialite\SocialiteManager; use Hypervel\Socialite\SocialiteServiceProvider; use Hypervel\Socialite\Testing\FakeProvider; use Hypervel\Socialite\Testing\SocialiteFake; @@ -25,7 +26,7 @@ protected function getPackageProviders($app): array return [SocialiteServiceProvider::class]; } - public function testItCanFakeADriverWithAUser() + public function testItCanFakeADriverWithAUser(): void { $user = (new OAuth2User)->map([ 'id' => '123', @@ -45,7 +46,7 @@ public function testItCanFakeADriverWithAUser() $this->assertSame('test@example.com', $retrievedUser->getEmail()); } - public function testItCanFakeADriverWithAClosure() + public function testItCanFakeADriverWithAClosure(): void { Socialite::fake('github', function () { return (new OAuth2User)->map([ @@ -61,7 +62,7 @@ public function testItCanFakeADriverWithAClosure() $this->assertSame('Closure User', $user->getName()); } - public function testItCanFakeMultipleDrivers() + public function testItCanFakeMultipleDrivers(): void { Socialite::fake('github', (new OAuth2User)->map(['id' => 'github-123'])); Socialite::fake('google', (new OAuth2User)->map(['id' => 'google-456'])); @@ -80,7 +81,31 @@ public function testItCanResolveAFakedDriverUsingAnIntegerEnum(): void $this->assertSame('enum-123', $provider->user()->getId()); } - public function testItReturnsFakeRedirectResponse() + public function testOAuthTwoUserFakeHasDefaultsAndAcceptsOverrides(): void + { + $default = OAuth2User::fake(); + + $this->assertSame('123456789', $default->id); + $this->assertSame('fake-token', $default->token); + $this->assertSame('fake-refresh-token', $default->refreshToken); + $this->assertSame(3600, $default->expiresIn); + $this->assertSame([], $default->approvedScopes); + $this->assertSame([], $default->accessTokenResponseBody); + + $overridden = OAuth2User::fake([ + 'id' => 'custom-id', + 'token' => 'custom-token', + 'approvedScopes' => ['read'], + 'accessTokenResponseBody' => ['token_type' => 'Bearer'], + ]); + + $this->assertSame('custom-id', $overridden->id); + $this->assertSame('custom-token', $overridden->token); + $this->assertSame(['read'], $overridden->approvedScopes); + $this->assertSame(['token_type' => 'Bearer'], $overridden->accessTokenResponseBody); + } + + public function testItReturnsFakeRedirectResponse(): void { Socialite::fake('github', (new OAuth2User)->map(['id' => '123'])); @@ -89,7 +114,7 @@ public function testItReturnsFakeRedirectResponse() $this->assertSame('https://socialite.fake/github/authorize', $response->getTargetUrl()); } - public function testItForwardsCallsToTheRealProviderMethods() + public function testItForwardsCallsToTheRealProviderMethods(): void { $this->app->make('config')->set('services.github', [ 'client_id' => 'test-client-id', @@ -115,7 +140,7 @@ public function testItForwardsCallsToTheRealProviderMethods() $this->assertSame('123', $user->getId()); } - public function testItPreservesDecoratorPatternWhenChainingMethods() + public function testItPreservesDecoratorPatternWhenChainingMethods(): void { $this->app->make('config')->set('services.github', [ 'client_id' => 'test-client-id', @@ -142,7 +167,7 @@ public function testItPreservesDecoratorPatternWhenChainingMethods() $this->assertSame('123', $user->getId()); } - public function testItReturnsRealDriverWhenNotFaked() + public function testItReturnsRealDriverWhenNotFaked(): void { $this->app->make('config')->set('services.github', [ 'client_id' => 'test-client-id', @@ -165,4 +190,14 @@ public function testItReturnsRealDriverWhenNotFaked() // Google should return the real provider since it wasn't faked $this->assertInstanceOf(GoogleProvider::class, Socialite::driver('google')); } + + public function testFactoryFakeDoesNotReplaceTheConcreteManager(): void + { + $manager = $this->app->make(SocialiteManager::class); + + Socialite::fake('github', OAuth2User::fake()); + + $this->assertInstanceOf(SocialiteFake::class, $this->app->make(Factory::class)); + $this->assertSame($manager, $this->app->make(SocialiteManager::class)); + } } From df2c1cbe08f2ef42b6c98354c86e9db654c9eaba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:37:48 +0000 Subject: [PATCH 06/14] socialite: harden OIDC and share JWKS caching Use one exact-URL, bounded JWKS implementation for generic OIDC, Google, and Facebook, with cache-control expiry, atomic publication, and one throttled rotation retry. Correct issuer, audience, nonce, discovery, and failure-class behavior; preserve complete token-response mapping without worker-retained response state; and remove the obsolete phpseclib dependency. The coverage exercises tenant URL switching, malformed metadata, cache directives, failed refresh cooldowns, key rotation, disabled nonce flows, scalar and list audiences, and provider-specific validation. --- composer.json | 1 - src/socialite/composer.json | 1 - .../src/Two/Concerns/InteractsWithJwks.php | 143 +++++ .../ConfigurationFetchingException.php | 4 +- .../InvalidUserInfoUrlException.php | 4 +- src/socialite/src/Two/FacebookProvider.php | 53 +- src/socialite/src/Two/GoogleProvider.php | 66 +-- src/socialite/src/Two/OpenIdProvider.php | 168 ++---- tests/Socialite/FacebookProviderTest.php | 181 +++++- .../Fixtures/OpenIdTestProviderStub.php | 36 +- .../VerifyingOpenIdTestProviderStub.php | 16 +- tests/Socialite/GoogleProviderIdTokenTest.php | 253 +++++---- tests/Socialite/GoogleProviderTest.php | 2 +- tests/Socialite/OpenIdProviderTest.php | 526 +++++++++++++++++- tests/Socialite/PackageMetadataTest.php | 57 ++ 15 files changed, 1125 insertions(+), 386 deletions(-) create mode 100644 src/socialite/src/Two/Concerns/InteractsWithJwks.php create mode 100644 tests/Socialite/PackageMetadataTest.php diff --git a/composer.json b/composer.json index 960a69d65..a9ecc7a7e 100644 --- a/composer.json +++ b/composer.json @@ -180,7 +180,6 @@ "nyholm/psr7": "^1.0", "paragonie/constant_time_encoding": "^3.1", "phpoption/phpoption": "^1.9", - "phpseclib/phpseclib": "^3.0", "psr/clock": "^1.0", "psr/container": "^2.0.1", "psr/http-client": "^1.0", diff --git a/src/socialite/composer.json b/src/socialite/composer.json index 94e9a4996..41c2ec970 100644 --- a/src/socialite/composer.json +++ b/src/socialite/composer.json @@ -33,7 +33,6 @@ "ext-json": "*", "firebase/php-jwt": "^7.0", "guzzlehttp/guzzle": "^7.15.1", - "phpseclib/phpseclib": "^3.0", "hypervel/collections": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", diff --git a/src/socialite/src/Two/Concerns/InteractsWithJwks.php b/src/socialite/src/Two/Concerns/InteractsWithJwks.php new file mode 100644 index 000000000..c07f4e6f7 --- /dev/null +++ b/src/socialite/src/Two/Concerns/InteractsWithJwks.php @@ -0,0 +1,143 @@ +getJwks()); + } catch (SignatureInvalidException) { + return (array) JWT::decode($token, $this->getJwks(refresh: true)); + } catch (UnexpectedValueException $exception) { + if (! str_contains($exception->getMessage(), '"kid" invalid')) { + throw $exception; + } + + return (array) JWT::decode($token, $this->getJwks(refresh: true)); + } + } + + /** + * Get the parsed JSON Web Key Set for the provider. + */ + private function getJwks(bool $refresh = false): array + { + $url = $this->getJwksUri(); + $now = time(); + + if (! $refresh + && ($this->jwks['url'] ?? null) === $url + && ($this->jwks['expiresAt'] === null || $now < $this->jwks['expiresAt'])) { + return $this->jwks['keys']; + } + + if ($refresh) { + if (($this->jwks['url'] ?? null) === $url + && ($this->jwksRefreshAttempt['url'] ?? null) === $url + && ($now - $this->jwksRefreshAttempt['attemptedAt']) < $this->jwksRefreshCooldownSeconds) { + return $this->jwks['keys']; + } + + $this->jwksRefreshAttempt = ['url' => $url, 'attemptedAt' => $now]; + + $refreshedUrl = $this->getJwksUri(refresh: true); + + if ($refreshedUrl !== $url) { + $url = $refreshedUrl; + $this->jwksRefreshAttempt['url'] = $url; + } + } + + $response = $this->getHttpClient()->get($url); + $keySet = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + + if (! is_array($keySet)) { + throw new UnexpectedValueException('The JWKS response must be a JSON object.'); + } + + $keys = JWK::parseKeySet($keySet); + $expiresAt = $this->getJwksExpiresAt($response, $now); + + $this->jwks = compact('url', 'keys', 'expiresAt'); + + return $this->jwks['keys']; + } + + /** + * Get the expiration timestamp from the response cache directives. + */ + private function getJwksExpiresAt(ResponseInterface $response, int $now): ?int + { + $maxAge = null; + + foreach ($response->getHeader('Cache-Control') as $header) { + foreach (explode(',', $header) as $directive) { + [$name, $value] = array_pad(explode('=', trim($directive), 2), 2, null); + $name = strtolower(trim($name)); + + if ($name === 'no-cache' || $name === 'no-store') { + return $now; + } + + if ($name !== 'max-age' || $value === null) { + continue; + } + + $value = trim($value); + + if (! ctype_digit($value)) { + continue; + } + + $normalized = ltrim($value, '0'); + $seconds = filter_var($normalized === '' ? '0' : $normalized, FILTER_VALIDATE_INT); + + if ($seconds === false || $seconds > PHP_INT_MAX - $now) { + continue; + } + + $maxAge = $maxAge === null ? $seconds : min($maxAge, $seconds); + } + } + + return $maxAge === null ? null : $now + $maxAge; + } +} diff --git a/src/socialite/src/Two/Exceptions/ConfigurationFetchingException.php b/src/socialite/src/Two/Exceptions/ConfigurationFetchingException.php index a71076ad6..e6e50e6ce 100644 --- a/src/socialite/src/Two/Exceptions/ConfigurationFetchingException.php +++ b/src/socialite/src/Two/Exceptions/ConfigurationFetchingException.php @@ -4,8 +4,8 @@ namespace Hypervel\Socialite\Two\Exceptions; -use InvalidArgumentException; +use RuntimeException; -class ConfigurationFetchingException extends InvalidArgumentException +class ConfigurationFetchingException extends RuntimeException { } diff --git a/src/socialite/src/Two/Exceptions/InvalidUserInfoUrlException.php b/src/socialite/src/Two/Exceptions/InvalidUserInfoUrlException.php index af2e536f4..c20a4fbda 100644 --- a/src/socialite/src/Two/Exceptions/InvalidUserInfoUrlException.php +++ b/src/socialite/src/Two/Exceptions/InvalidUserInfoUrlException.php @@ -4,8 +4,8 @@ namespace Hypervel\Socialite\Two\Exceptions; -use InvalidArgumentException; +use RuntimeException; -class InvalidUserInfoUrlException extends InvalidArgumentException +class InvalidUserInfoUrlException extends RuntimeException { } diff --git a/src/socialite/src/Two/FacebookProvider.php b/src/socialite/src/Two/FacebookProvider.php index 5bc3bc066..e659ea60b 100644 --- a/src/socialite/src/Two/FacebookProvider.php +++ b/src/socialite/src/Two/FacebookProvider.php @@ -4,16 +4,17 @@ namespace Hypervel\Socialite\Two; -use Exception; use Firebase\JWT\JWT; -use Firebase\JWT\Key; use GuzzleHttp\RequestOptions; +use Hypervel\Socialite\Two\Concerns\InteractsWithJwks; +use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Support\Arr; -use phpseclib3\Crypt\RSA; -use phpseclib3\Math\BigInteger; +use SensitiveParameter; class FacebookProvider extends AbstractProvider implements ProviderInterface { + use InteractsWithJwks; + /** * The base Facebook Graph URL. */ @@ -54,7 +55,7 @@ protected function getTokenUrl(): string return $this->graphUrl . '/' . $this->getGraphVersion() . '/oauth/access_token'; } - public function getAccessTokenResponse(string $code): array + public function getAccessTokenResponse(#[SensitiveParameter] string $code): array { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::FORM_PARAMS => $this->getTokenFields($code), @@ -65,7 +66,7 @@ public function getAccessTokenResponse(string $code): array return Arr::add($data, 'expires_in', Arr::pull($data, 'expires')); } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $this->setLastToken($token); @@ -76,18 +77,28 @@ protected function getUserByToken(string $token): array /** * Get user based on the OIDC token. */ - protected function getUserByOIDCToken(string $token): ?array + protected function getUserByOIDCToken(#[SensitiveParameter] string $token): ?array { - $kid = json_decode(base64_decode(explode('.', $token)[0]), true)['kid'] ?? null; + $segments = explode('.', $token); + + if (count($segments) !== 3) { + return null; + } + + $header = json_decode(JWT::urlsafeB64Decode($segments[0]), true); + $kid = is_array($header) ? ($header['kid'] ?? null) : null; if ($kid === null) { return null; } - $data = (array) JWT::decode($token, $this->getPublicKeyOfOIDCToken($kid)); + $data = $this->decodeUsingJwks($token); - throw_if($data['aud'] !== $this->getClientId(), new Exception('Token has incorrect audience.')); - throw_if($data['iss'] !== 'https://www.facebook.com', new Exception('Token has incorrect issuer.')); + $this->validateAudience($data['aud'] ?? null); + + if (($data['iss'] ?? null) !== 'https://www.facebook.com') { + throw new InvalidIssuerException; + } $data['id'] = $data['sub']; @@ -103,27 +114,17 @@ protected function getUserByOIDCToken(string $token): ?array } /** - * Get the public key to verify the signature of OIDC token. + * Get Facebook's JSON Web Key Set URI. */ - protected function getPublicKeyOfOIDCToken(string $kid): Key + protected function getJwksUri(bool $refresh = false): string { - $response = $this->getHttpClient()->get('https://limited.facebook.com/.well-known/oauth/openid/jwks/'); - - $key = Arr::first(json_decode($response->getBody()->getContents(), true)['keys'], function ($key) use ($kid) { - return $key['kid'] === $kid; - }); - - $key['n'] = new BigInteger(JWT::urlsafeB64Decode($key['n']), 256); - $key['e'] = new BigInteger(JWT::urlsafeB64Decode($key['e']), 256); - - // @phpstan-ignore-next-line - return new Key((string) RSA::load($key), 'RS256'); + return 'https://limited.facebook.com/.well-known/oauth/openid/jwks/'; } /** * Get user based on the access token. */ - protected function getUserFromAccessToken(string $token): array + protected function getUserFromAccessToken(#[SensitiveParameter] string $token): array { $params = [ 'access_token' => $token, @@ -237,7 +238,7 @@ public function lastToken(): ?string /** * Set the last access token used. */ - protected function setLastToken(string $token): static + protected function setLastToken(#[SensitiveParameter] string $token): static { $this->setContext('lastToken', $token); diff --git a/src/socialite/src/Two/GoogleProvider.php b/src/socialite/src/Two/GoogleProvider.php index 8e6f9fda6..a787e22fa 100644 --- a/src/socialite/src/Two/GoogleProvider.php +++ b/src/socialite/src/Two/GoogleProvider.php @@ -4,14 +4,16 @@ namespace Hypervel\Socialite\Two; -use Exception; -use Firebase\JWT\JWK; -use Firebase\JWT\JWT; use GuzzleHttp\RequestOptions; +use Hypervel\Socialite\Two\Concerns\InteractsWithJwks; +use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Support\Arr; +use SensitiveParameter; class GoogleProvider extends AbstractProvider implements ProviderInterface { + use InteractsWithJwks; + /** * The separating character for the requested scopes. */ @@ -36,7 +38,7 @@ protected function getTokenUrl(): string return 'https://www.googleapis.com/oauth2/v4/token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { if ($this->isJwtToken($token)) { return $this->getUserFromJwtToken($token); @@ -55,18 +57,6 @@ protected function getUserByToken(string $token): array return json_decode((string) $response->getBody(), true); } - public function refreshToken(string $refreshToken): Token - { - $response = $this->getRefreshTokenResponse($refreshToken); - - return new Token( - Arr::get($response, 'access_token'), - Arr::get($response, 'refresh_token', $refreshToken), - Arr::get($response, 'expires_in'), - explode($this->scopeSeparator, Arr::get($response, 'scope', '')) - ); - } - protected function mapUserToObject(array $user): User { return (new User)->setRaw($user)->map([ @@ -82,47 +72,35 @@ protected function mapUserToObject(array $user): User /** * Determine if the given token is a JWT (ID token). */ - protected function isJwtToken(string $token): bool + protected function isJwtToken(#[SensitiveParameter] string $token): bool { return substr_count($token, '.') === 2 && strlen($token) > 100; } /** * Get user data from a Google ID token (JWT). - * - * @throws Exception */ - protected function getUserFromJwtToken(string $idToken): array + protected function getUserFromJwtToken(#[SensitiveParameter] string $idToken): array { - try { - $user = (array) JWT::decode( - $idToken, - JWK::parseKeySet($this->getGoogleJwks()) - ); - - if (! isset($user['iss']) || $user['iss'] !== 'https://accounts.google.com') { - throw new Exception('Invalid ID token issuer.'); - } - - if (! isset($user['aud']) || $user['aud'] !== $this->getClientId()) { - throw new Exception('Invalid ID token audience.'); - } - - return $user; - } catch (Exception $e) { - throw new Exception('Failed to verify Google JWT token: ' . $e->getMessage()); + $user = $this->decodeUsingJwks($idToken); + + if (! isset($user['iss']) || ! in_array($user['iss'], [ + 'accounts.google.com', + 'https://accounts.google.com', + ], true)) { + throw new InvalidIssuerException; } + + $this->validateAudience($user['aud'] ?? null); + + return $user; } /** - * Get Google's JSON Web Key Set for JWT verification. + * Get Google's JSON Web Key Set URI. */ - protected function getGoogleJwks(): array + protected function getJwksUri(bool $refresh = false): string { - $response = $this->getHttpClient()->get( - 'https://www.googleapis.com/oauth2/v3/certs' - ); - - return json_decode((string) $response->getBody(), true); + return 'https://www.googleapis.com/oauth2/v3/certs'; } } diff --git a/src/socialite/src/Two/OpenIdProvider.php b/src/socialite/src/Two/OpenIdProvider.php index a1498d14b..b18381585 100644 --- a/src/socialite/src/Two/OpenIdProvider.php +++ b/src/socialite/src/Two/OpenIdProvider.php @@ -4,22 +4,22 @@ namespace Hypervel\Socialite\Two; -use Firebase\JWT\JWK; -use Firebase\JWT\JWT; -use Firebase\JWT\SignatureInvalidException; use GuzzleHttp\RequestOptions; use Hypervel\Http\RedirectResponse; +use Hypervel\Socialite\Two\Concerns\InteractsWithJwks; use Hypervel\Socialite\Two\Exceptions\ConfigurationFetchingException; -use Hypervel\Socialite\Two\Exceptions\InvalidAudienceException; use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Socialite\Two\Exceptions\InvalidNonceException; use Hypervel\Socialite\Two\Exceptions\InvalidUserInfoUrlException; use Hypervel\Support\Str; +use SensitiveParameter; use Throwable; use UnexpectedValueException; abstract class OpenIdProvider extends AbstractProvider { + use InteractsWithJwks; + /** * Indicates if the nonce should be utilized. */ @@ -27,24 +27,10 @@ abstract class OpenIdProvider extends AbstractProvider /** * The OpenID Connect configuration. + * + * @var null|array{url: string, config: array} */ - protected array $openidConfig = []; - - /** - * The JSON Web Key Set (JWKS) for the provider. - * This is used to verify the JWT tokens. - */ - protected ?array $jwks = null; - - /** - * The timestamp of the last forced JWKS refresh attempt. - */ - protected ?int $jwksRefreshAttemptedAt = null; - - /** - * The minimum seconds between forced JWKS refreshes. - */ - protected int $jwksRefreshCooldownSeconds = 10; + protected ?array $openidConfig = null; /** * Get the base URL for the OIDC provider. @@ -153,13 +139,7 @@ protected function getNonce(): string */ protected function getCurrentNonce(): ?string { - $nonce = null; - - if ($this->getRequest()->session()->has('nonce')) { - $nonce = $this->getRequest()->session()->get('nonce'); - } - - return $nonce; + return $this->getRequest()->session()->pull('nonce'); } /** @@ -167,19 +147,29 @@ protected function getCurrentNonce(): ?string */ protected function getOpenIdConfig(bool $refresh = false): array { - if ($this->openidConfig && ! $refresh) { - return $this->openidConfig; - } + $url = $this->getOpenIdConfigUrl(); - $configUrl = $this->getOpenIdConfigUrl(); + if (! $refresh && ($this->openidConfig['url'] ?? null) === $url) { + return $this->openidConfig['config']; + } try { - $response = $this->getHttpClient()->get($configUrl); + $response = $this->getHttpClient()->get($url); + $config = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - return $this->openidConfig = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); - } catch (Throwable $e) { - throw new ConfigurationFetchingException('Unable to get the OIDC configuration from ' . $configUrl . ': ' . $e->getMessage()); + if (! is_array($config) || array_is_list($config)) { + throw new UnexpectedValueException('The OIDC configuration response must be a JSON object with named fields.'); + } + } catch (Throwable $exception) { + throw new ConfigurationFetchingException( + 'Unable to get the OIDC configuration from ' . $url . ': ' . $exception->getMessage(), + previous: $exception, + ); } + + $this->openidConfig = ['url' => $url, 'config' => $config]; + + return $this->openidConfig['config']; } /** @@ -191,66 +181,10 @@ protected function getOpenIdConfigUrl(): string return rtrim($this->getBaseUrl(), '/') . '/.well-known/openid-configuration'; } - /** - * Get the JSON Web Key Set (JWKS) for the provider. - */ - protected function getJwks(bool $refresh = false): array - { - if ($this->jwks && ! $refresh) { - return $this->jwks; - } - - if ($this->jwks && ! $this->canRefreshJwks($refresh)) { - return $this->jwks; - } - - if ($refresh) { - $this->jwksRefreshAttemptedAt = time(); - } - - $response = $this->getHttpClient() - ->get($this->getJwksUri($refresh)); - - return $this->jwks = JWK::parseKeySet( - json_decode((string) $response->getBody(), true) - ); - } - - /** - * Determine if the JWKS can be force-refreshed. - */ - protected function canRefreshJwks(bool $refresh): bool - { - return ! $refresh - || $this->jwksRefreshAttemptedAt === null - || (time() - $this->jwksRefreshAttemptedAt) >= $this->jwksRefreshCooldownSeconds; - } - - /** - * Receive data from auth/callback route - * code, id_token, scope, state, session_state. - */ - public function user(): User - { - if ($user = $this->getUser()) { - return $user; - } - - if ($this->hasInvalidState()) { - throw new InvalidStateException; - } - - $user = $this->getUserByTokenResponse( - $response = $this->getAccessTokenResponse($this->getCode()) - ); - - return $this->userInstance($response, $user); - } - /** * Get user data by the response from the provider. */ - protected function getUserByTokenResponse(array $response): ?array + protected function getUserByTokenResponse(#[SensitiveParameter] array $response): array { return $this->getUserByOIDCToken($response['id_token']); } @@ -271,20 +205,9 @@ protected function isInvalidNonce(string $nonce): bool /** * Get user based on the OIDC token. */ - protected function getUserByOIDCToken(string $token): ?array + protected function getUserByOIDCToken(#[SensitiveParameter] string $token): array { - try { - $data = (array) JWT::decode($token, $this->getJwks()); - } catch (SignatureInvalidException) { - // Some providers briefly replace key material under an existing kid. - $data = (array) JWT::decode($token, $this->getJwks(refresh: true)); - } catch (UnexpectedValueException $e) { - if (! str_contains($e->getMessage(), '"kid" invalid')) { - throw $e; - } - - $data = (array) JWT::decode($token, $this->getJwks(refresh: true)); - } + $data = $this->decodeUsingJwks($token); $this->validateOIDCPayload($data); @@ -296,47 +219,32 @@ protected function getUserByOIDCToken(string $token): ?array */ protected function validateOIDCPayload(array $data): void { - if (! isset($data['nonce']) || $this->isInvalidNonce($data['nonce'])) { + if ($this->usesNonce() && (! isset($data['nonce']) || $this->isInvalidNonce($data['nonce']))) { throw new InvalidNonceException; } - if (! isset($data['aud']) || $data['aud'] !== $this->getClientId()) { - throw new InvalidAudienceException; - } + $this->validateAudience($data['aud'] ?? null); if (! isset($data['iss']) || $data['iss'] !== $this->getOpenIdConfig()['issuer']) { throw new InvalidIssuerException; } } - protected function appendOIDCPayload(array $payload): array - { - if ($this->usesNonce()) { - $payload['nonce'] = $this->getCurrentNonce(); - } - - return $payload; - } - /** * Get the raw user for the given access token. */ - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { if (! $userInfoUrl = $this->getUserInfoUrl()) { throw new InvalidUserInfoUrlException; } - $response = $this->getHttpClient()->get( - $userInfoUrl . '?' . http_build_query([ - 'access_token' => $token, - ]), - [ - RequestOptions::HEADERS => [ - 'Accept' => 'application/json', - ], - ] - ); + $response = $this->getHttpClient()->get($userInfoUrl, [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer ' . $token, + ], + ]); return json_decode((string) $response->getBody(), true); } diff --git a/tests/Socialite/FacebookProviderTest.php b/tests/Socialite/FacebookProviderTest.php index b34d4441c..e54137db6 100644 --- a/tests/Socialite/FacebookProviderTest.php +++ b/tests/Socialite/FacebookProviderTest.php @@ -4,23 +4,24 @@ namespace Hypervel\Tests\Socialite; +use Firebase\JWT\JWT; +use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\RequestOptions; use Hypervel\Http\Request; +use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Socialite\Two\FacebookProvider; use Hypervel\Socialite\Two\User; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionMethod; +use UnexpectedValueException; class FacebookProviderTest extends TestCase { - public function testMapUserToObjectWithAccessTokenResponse() + public function testMapUserToObjectWithAccessTokenResponse(): void { - $provider = new FacebookProvider( - m::mock(Request::class), - 'client_id', - 'client_secret', - 'redirect' - ); + $provider = $this->getProvider(); $method = new ReflectionMethod($provider, 'mapUserToObject'); @@ -41,14 +42,9 @@ public function testMapUserToObjectWithAccessTokenResponse() $this->assertSame('https://platform-lookaside.fbsbx.com/photo.jpg', $user->avatar_original); } - public function testMapUserToObjectWithOidcTokenResponse() + public function testMapUserToObjectWithOidcTokenResponse(): void { - $provider = new FacebookProvider( - m::mock(Request::class), - 'client_id', - 'client_secret', - 'redirect' - ); + $provider = $this->getProvider(); $method = new ReflectionMethod($provider, 'mapUserToObject'); @@ -64,4 +60,161 @@ public function testMapUserToObjectWithOidcTokenResponse() $this->assertSame('https://platform-lookaside.fbsbx.com/oidc-photo.jpg', $user->getAvatar()); $this->assertSame('https://platform-lookaside.fbsbx.com/oidc-photo.jpg', $user->avatar_original); } + + public function testItAcceptsConfiguredTrustedAudiences(): void + { + $provider = $this->getProvider(); + $provider->setConfig(['trusted_audiences' => ['trusted-api']]); + $key = $this->createRsaKeyPair('current-key'); + + $this->expectJwksResponses($provider, [$key]); + + $user = $provider->userFromToken($this->createSignedToken( + $key, + audience: ['client_id', 'trusted-api'], + )); + + $this->assertSame('123456', $user->getId()); + } + + public function testItRejectsAnInvalidIssuerWithTheNamedException(): void + { + $provider = $this->getProvider(); + $key = $this->createRsaKeyPair('current-key'); + + $this->expectJwksResponses($provider, [$key]); + $this->expectException(InvalidIssuerException::class); + + $provider->userFromToken($this->createSignedToken($key, issuer: 'https://invalid-issuer.example')); + } + + public function testItRefreshesJwksOnceForAChangedKey(): void + { + $provider = $this->getProvider(); + $oldKey = $this->createRsaKeyPair('old-key'); + $newKey = $this->createRsaKeyPair('new-key'); + + $this->expectJwksResponses($provider, [$oldKey, $newKey]); + + $this->assertSame('123456', $provider->userFromToken($this->createSignedToken($oldKey))->getId()); + $this->assertSame('123456', $provider->userFromToken($this->createSignedToken($newKey))->getId()); + } + + public function testAnUnknownKidRaisesTheLibraryAuthenticationFailure(): void + { + $provider = $this->getProvider(); + $knownKey = $this->createRsaKeyPair('known-key'); + $unknownKey = $this->createRsaKeyPair('unknown-key'); + + $this->expectJwksResponses($provider, [$knownKey, $knownKey]); + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('"kid" invalid, unable to lookup correct key'); + + $provider->userFromToken($this->createSignedToken($unknownKey)); + } + + public function testAccessTokenProfileRequestPreservesDocumentedQueryParameters(): void + { + $provider = $this->getProvider(); + $httpClient = m::mock(Client::class); + $provider->setHttpClient($httpClient); + + $httpClient->expects('get')->with('https://graph.facebook.com/v23.0/me', [ + RequestOptions::HEADERS => [ + 'Accept' => 'application/json', + ], + RequestOptions::QUERY => [ + 'access_token' => 'access-token', + 'fields' => 'name,email,gender,verified,link,picture.width(1920)', + 'appsecret_proof' => hash_hmac('sha256', 'access-token', 'client_secret'), + ], + ])->andReturn(new Response(body: json_encode([ + 'id' => '123456', + 'name' => 'Test User', + ]))); + + $this->assertSame('123456', $provider->userFromToken('access-token')->getId()); + } + + private function getProvider(): FacebookProvider + { + return new FacebookProvider( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect', + ); + } + + private function expectJwksResponses(FacebookProvider $provider, array $keys): void + { + $httpClient = m::mock(Client::class); + $provider->setHttpClient($httpClient); + + $httpClient->expects('get') + ->with('https://limited.facebook.com/.well-known/oauth/openid/jwks/') + ->times(count($keys)) + ->andReturn(...array_map( + fn (array $key): Response => new Response( + headers: ['Cache-Control' => 'max-age=3600'], + body: json_encode($this->jwks($key)), + ), + $keys, + )); + } + + private function createSignedToken( + array $key, + string $issuer = 'https://www.facebook.com', + array|string $audience = 'client_id', + ): string { + return JWT::encode([ + 'iss' => $issuer, + 'sub' => '123456', + 'aud' => $audience, + 'name' => 'Test User', + 'email' => 'test@example.com', + 'picture' => 'https://platform-lookaside.fbsbx.com/oidc-photo.jpg', + 'iat' => time(), + 'exp' => time() + 3600, + ], $key['private'], 'RS256', $key['kid']); + } + + private function createRsaKeyPair(string $kid): array + { + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($key === false) { + $this->fail('Unable to generate RSA key pair for Facebook ID token test.'); + } + + openssl_pkey_export($key, $privateKey); + $details = openssl_pkey_get_details($key); + + return [ + 'kid' => $kid, + 'private' => $privateKey, + 'jwk' => [ + 'kid' => $kid, + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']), + ], + ]; + } + + private function jwks(array $key): array + { + return ['keys' => [$key['jwk']]]; + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } } diff --git a/tests/Socialite/Fixtures/OpenIdTestProviderStub.php b/tests/Socialite/Fixtures/OpenIdTestProviderStub.php index 6fb469bac..12ddb46a7 100644 --- a/tests/Socialite/Fixtures/OpenIdTestProviderStub.php +++ b/tests/Socialite/Fixtures/OpenIdTestProviderStub.php @@ -8,17 +8,15 @@ use Hypervel\Socialite\Two\OpenIdProvider; use Hypervel\Socialite\Two\User; use Mockery as m; +use SensitiveParameter; class OpenIdTestProviderStub extends OpenIdProvider { - /** - * @var \GuzzleHttp\Client|\Mockery\MockInterface - */ - public $http; + public ?Client $http = null; protected function getBaseUrl(): string { - return 'http://base.url'; + return $this->getConfig('base_url', 'http://base.url'); } protected function getAuthUrl(?string $state, ?string $nonce = null): string @@ -31,15 +29,15 @@ protected function getTokenUrl(): string return 'http://token.url'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { - return ['id' => 'foo']; + return parent::getUserByToken($token); } /** * Get user based on the OIDC token. */ - protected function getUserByOIDCToken(string $token): ?array + protected function getUserByOIDCToken(#[SensitiveParameter] string $token): array { $this->validateOIDCPayload( $data = [ @@ -58,10 +56,28 @@ protected function mapUserToObject(array $user): User return (new User)->map(['id' => $user['sub']]); } + public function getProviderOpenIdConfig(bool $refresh = false): array + { + return $this->getOpenIdConfig($refresh); + } + + public function validateProviderPayload(array $payload): void + { + $this->validateOIDCPayload($payload); + } + + public function getProviderUserByTokenResponse(#[SensitiveParameter] array $response): array + { + return $this->getUserByTokenResponse($response); + } + + public function getProviderUserByToken(#[SensitiveParameter] string $token): array + { + return $this->getUserByToken($token); + } + /** * Get a fresh instance of the Guzzle HTTP client. - * - * @return \GuzzleHttp\Client|\Mockery\MockInterface */ protected function getHttpClient(): Client { diff --git a/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php b/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php index 36805f853..203f22999 100644 --- a/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php +++ b/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php @@ -8,15 +8,15 @@ use Hypervel\Socialite\Two\OpenIdProvider; use Hypervel\Socialite\Two\User; use Mockery as m; +use SensitiveParameter; class VerifyingOpenIdTestProviderStub extends OpenIdProvider { - /** - * @var \GuzzleHttp\Client|\Mockery\MockInterface - */ - public $http; + protected bool $usesNonce = false; + + public ?Client $http = null; - public function verifyToken(string $token): ?array + public function verifyToken(#[SensitiveParameter] string $token): array { return $this->getUserByOIDCToken($token); } @@ -28,7 +28,7 @@ public function setJwksRefreshCooldownSeconds(int $seconds): void protected function getBaseUrl(): string { - return 'http://base.url'; + return $this->getConfig('base_url', 'http://base.url'); } protected function getAuthUrl(?string $state, ?string $nonce = null): string @@ -41,7 +41,7 @@ protected function getTokenUrl(): string return 'http://token.url'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { return ['id' => 'foo']; } @@ -53,8 +53,6 @@ protected function mapUserToObject(array $user): User /** * Get a fresh instance of the Guzzle HTTP client. - * - * @return \GuzzleHttp\Client|\Mockery\MockInterface */ protected function getHttpClient(): Client { diff --git a/tests/Socialite/GoogleProviderIdTokenTest.php b/tests/Socialite/GoogleProviderIdTokenTest.php index 64d863659..093f151c4 100644 --- a/tests/Socialite/GoogleProviderIdTokenTest.php +++ b/tests/Socialite/GoogleProviderIdTokenTest.php @@ -4,10 +4,13 @@ namespace Hypervel\Tests\Socialite; -use Exception; +use Firebase\JWT\JWT; use GuzzleHttp\Client; +use GuzzleHttp\Psr7\Response; use GuzzleHttp\RequestOptions; use Hypervel\Http\Request; +use Hypervel\Socialite\Two\Exceptions\InvalidAudienceException; +use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Socialite\Two\GoogleProvider; use Hypervel\Socialite\Two\User; use Hypervel\Tests\TestCase; @@ -19,33 +22,95 @@ class GoogleProviderIdTokenTest extends TestCase { - public function testItCanDetectJwtTokens() + public function testItCanDetectJwtTokens(): void { $provider = $this->getProvider(); - - $jwtToken = $this->createMockJwtToken(); - $accessToken = 'ya29.a0AfH6SMCxyz123456789'; + $key = $this->createRsaKeyPair('current-key'); $method = new ReflectionMethod($provider, 'isJwtToken'); - $this->assertTrue($method->invoke($provider, $jwtToken)); - $this->assertFalse($method->invoke($provider, $accessToken)); + $this->assertTrue($method->invoke($provider, $this->createSignedToken($key))); + $this->assertFalse($method->invoke($provider, 'ya29.a0AfH6SMCxyz123456789')); + } + + #[DataProvider('validIssuerProvider')] + public function testItAcceptsTheDocumentedIssuers(string $issuer): void + { + $provider = $this->getProvider(); + $key = $this->createRsaKeyPair('current-key'); + + $this->expectJwksResponses($provider, [$key]); + + $user = $provider->userFromToken($this->createSignedToken($key, issuer: $issuer)); + + $this->assertSame('123456789012345678901', $user->getId()); + } + + public static function validIssuerProvider(): array + { + return [ + 'bare issuer' => ['accounts.google.com'], + 'HTTPS issuer' => ['https://accounts.google.com'], + ]; + } + + public function testItRejectsAnInvalidIssuerWithTheNamedException(): void + { + $provider = $this->getProvider(); + $key = $this->createRsaKeyPair('current-key'); + + $this->expectJwksResponses($provider, [$key]); + $this->expectException(InvalidIssuerException::class); + + $provider->userFromToken($this->createSignedToken($key, issuer: 'https://invalid-issuer.example')); + } + + public function testItRejectsAnInvalidAudienceWithTheNamedException(): void + { + $provider = $this->getProvider(); + $key = $this->createRsaKeyPair('current-key'); + + $this->expectJwksResponses($provider, [$key]); + $this->expectException(InvalidAudienceException::class); + + $provider->userFromToken($this->createSignedToken($key, audience: 'another-client')); } - public function testItUsesJwtVerificationForIdTokens() + public function testItAcceptsConfiguredTrustedAudiences(): void { $provider = $this->getProvider(); - $idToken = $this->createMockJwtToken(); + $provider->setConfig(['trusted_audiences' => 'trusted-api']); + $key = $this->createRsaKeyPair('current-key'); + + $this->expectJwksResponses($provider, [$key]); - $this->mockJwksResponse($provider); + $user = $provider->userFromToken($this->createSignedToken( + $key, + audience: ['test-client-id', 'trusted-api'], + )); + + $this->assertSame('123456789012345678901', $user->getId()); + } + + public function testItRefreshesJwksOnceForAChangedKey(): void + { + $provider = $this->getProvider(); + $oldKey = $this->createRsaKeyPair('old-key'); + $newKey = $this->createRsaKeyPair('new-key'); - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Failed to verify Google JWT token/'); + $this->expectJwksResponses($provider, [$oldKey, $newKey]); - $provider->userFromToken($idToken); + $this->assertSame( + '123456789012345678901', + $provider->userFromToken($this->createSignedToken($oldKey))->getId(), + ); + $this->assertSame( + '123456789012345678901', + $provider->userFromToken($this->createSignedToken($newKey))->getId(), + ); } - public function testItFallsBackToApiCallForAccessTokens() + public function testItFallsBackToApiCallForAccessTokens(): void { $provider = $this->getProvider(); $accessToken = 'ya29.a0AfH6SMCxyz123456789'; @@ -92,40 +157,7 @@ public function testItFallsBackToApiCallForAccessTokens() $this->assertSame('Test User', $user->getName()); } - #[DataProvider('invalidJwtProvider')] - public function testItHandlesInvalidJwtTokens(string $description, array $tokenOverrides, bool $expectedException = true) - { - $provider = $this->getProvider(); - $invalidToken = $this->createInvalidJwtToken($tokenOverrides); - - if ($expectedException) { - $this->mockJwksResponse($provider); - $this->expectException(Exception::class); - $this->expectExceptionMessageMatches('/Failed to verify Google JWT token/'); - } - - $provider->userFromToken($invalidToken); - } - - public static function invalidJwtProvider(): array - { - return [ - 'invalid issuer' => [ - 'Invalid issuer', - ['payload' => ['iss' => 'https://invalid-issuer.com']], - ], - 'invalid audience' => [ - 'Invalid audience', - ['payload' => ['aud' => 'wrong-client-id']], - ], - 'missing key id' => [ - 'Missing key ID', - ['header' => ['kid' => null]], - ], - ]; - } - - public function testUserMappingWorksWithIdTokenFormat() + public function testUserMappingWorksWithIdTokenFormat(): void { $provider = $this->getProvider(); @@ -161,99 +193,76 @@ protected function getProvider(): GoogleProvider ); } - /** - * Create a mock JWT token for testing. - */ - protected function createMockJwtToken(): string + private function expectJwksResponses(GoogleProvider $provider, array $keys): void { - $header = ['typ' => 'JWT', 'alg' => 'RS256', 'kid' => 'test-key-id']; - $payload = [ - 'iss' => 'https://accounts.google.com', + $httpClient = m::mock(Client::class); + $provider->setHttpClient($httpClient); + + $httpClient->expects('get') + ->with('https://www.googleapis.com/oauth2/v3/certs') + ->times(count($keys)) + ->andReturn(...array_map( + fn (array $key): Response => new Response( + headers: ['Cache-Control' => 'max-age=3600'], + body: json_encode($this->jwks($key)), + ), + $keys, + )); + } + + private function createSignedToken( + array $key, + string $issuer = 'https://accounts.google.com', + array|string $audience = 'test-client-id', + ): string { + return JWT::encode([ + 'iss' => $issuer, 'sub' => '123456789012345678901', - 'aud' => 'test-client-id', + 'aud' => $audience, 'email' => 'testuser@gmail.com', 'email_verified' => true, 'name' => 'Test User', 'picture' => 'https://lh3.googleusercontent.com/photo.jpg', 'iat' => time(), 'exp' => time() + 3600, - ]; - - return $this->base64UrlEncode(json_encode($header)) - . '.' - . $this->base64UrlEncode(json_encode($payload)) - . '.' - . $this->base64UrlEncode('mock-signature'); + ], $key['private'], 'RS256', $key['kid']); } - /** - * Mock JWKS response for testing JWT verification. - */ - protected function mockJwksResponse(GoogleProvider $provider): void + private function createRsaKeyPair(string $kid): array { - $httpClient = m::mock(Client::class); - $provider->setHttpClient($httpClient); + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); - $jwksResponse = m::mock(ResponseInterface::class); - $jwksStream = m::mock(StreamInterface::class); - - $mockJwks = [ - 'keys' => [ - [ - 'kid' => 'test-key-id', - 'kty' => 'RSA', - 'use' => 'sig', - 'n' => 'mock-n-value', - 'e' => 'AQAB', - ], - ], - ]; + if ($key === false) { + $this->fail('Unable to generate RSA key pair for Google ID token test.'); + } - $httpClient->shouldReceive('get') - ->with('https://www.googleapis.com/oauth2/v3/certs') - ->once() - ->andReturn($jwksResponse); + openssl_pkey_export($key, $privateKey); + $details = openssl_pkey_get_details($key); - $jwksResponse->shouldReceive('getBody')->once()->andReturn($jwksStream); - $jwksStream->shouldReceive('__toString')->once()->andReturn(json_encode($mockJwks)); + return [ + 'kid' => $kid, + 'private' => $privateKey, + 'jwk' => [ + 'kid' => $kid, + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']), + ], + ]; } - /** - * Create an invalid JWT token for testing. - */ - protected function createInvalidJwtToken(array $overrides): string + private function jwks(array $key): array { - $header = array_merge( - ['typ' => 'JWT', 'alg' => 'RS256', 'kid' => 'test-key-id'], - $overrides['header'] ?? [] - ); - - $payload = array_merge([ - 'iss' => 'https://accounts.google.com', - 'sub' => '123456789', - 'aud' => 'test-client-id', - 'email' => 'test@example.com', - 'name' => 'Test User', - 'iat' => time(), - 'exp' => time() + 3600, - ], $overrides['payload'] ?? []); - - $header = array_filter($header, function ($value) { - return $value !== null; - }); - - return $this->base64UrlEncode(json_encode($header)) - . '.' - . $this->base64UrlEncode(json_encode($payload)) - . '.' - . $this->base64UrlEncode('mock-signature'); + return ['keys' => [$key['jwk']]]; } - /** - * Base64URL encode data. - */ - protected function base64UrlEncode(string $data): string + private function base64UrlEncode(string $value): string { - return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); } } diff --git a/tests/Socialite/GoogleProviderTest.php b/tests/Socialite/GoogleProviderTest.php index 6935bd418..4d792d0cb 100644 --- a/tests/Socialite/GoogleProviderTest.php +++ b/tests/Socialite/GoogleProviderTest.php @@ -16,7 +16,7 @@ class GoogleProviderTest extends TestCase { - public function testMapUserFromAccessToken() + public function testMapUserFromAccessToken(): void { $provider = new GoogleTestProviderStub( m::mock(Request::class), diff --git a/tests/Socialite/OpenIdProviderTest.php b/tests/Socialite/OpenIdProviderTest.php index 09ec29e74..d69f94831 100644 --- a/tests/Socialite/OpenIdProviderTest.php +++ b/tests/Socialite/OpenIdProviderTest.php @@ -10,20 +10,26 @@ use Hypervel\Contracts\Session\Session as SessionContract; use Hypervel\Http\RedirectResponse; use Hypervel\Http\Request; +use Hypervel\Socialite\Two\Exceptions\ConfigurationFetchingException; use Hypervel\Socialite\Two\Exceptions\InvalidAudienceException; +use Hypervel\Socialite\Two\Exceptions\InvalidNonceException; +use Hypervel\Socialite\Two\Exceptions\InvalidUserInfoUrlException; use Hypervel\Socialite\Two\User; use Hypervel\Tests\Socialite\Fixtures\OpenIdTestProviderStub; use Hypervel\Tests\Socialite\Fixtures\VerifyingOpenIdTestProviderStub; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; use ReflectionMethod; +use RuntimeException; +use TypeError; use UnexpectedValueException; class OpenIdProviderTest extends TestCase { - public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE() + public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -63,7 +69,7 @@ public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE() ); } - public function testUserReturnsAUserInstanceForTheAuthenticatedRequest() + public function testUserReturnsAUserInstanceForTheAuthenticatedRequest(): void { $request = m::mock(Request::class); $request->shouldReceive('session') @@ -80,8 +86,7 @@ public function testUserReturnsAUserInstanceForTheAuthenticatedRequest() ->andReturn('code'); $session->expects('pull')->with('state')->andReturns(str_repeat('A', 40)); - $session->expects('has')->with('nonce')->andReturns(true); - $session->expects('get')->with('nonce')->andReturns('nonce'); + $session->expects('pull')->with('nonce')->andReturns('nonce'); $provider = new OpenIdTestProviderStub( $request, 'client_id', @@ -111,16 +116,21 @@ public function testUserReturnsAUserInstanceForTheAuthenticatedRequest() $this->assertSame('access_token', $user->token); $this->assertSame('refresh_token', $user->refreshToken); $this->assertSame(3600, $user->expiresIn); + $this->assertSame([ + 'access_token' => 'access_token', + 'id_token' => 'id_token', + 'refresh_token' => 'refresh_token', + 'expires_in' => 3600, + ], $user->accessTokenResponseBody); $this->assertSame($user->id, $provider->user()->id); } - public function testSetConfigOverridesAudienceValidationPass() + public function testSetConfigOverridesAudienceValidationPass(): void { $request = m::mock(Request::class); $request->shouldReceive('session') ->andReturn($session = m::mock(SessionContract::class)); - $session->allows('has')->with('nonce')->andReturns(true); - $session->allows('get')->with('nonce')->andReturns('test-nonce'); + $session->expects('pull')->with('nonce')->andReturns('test-nonce'); $provider = new OpenIdTestProviderStub( $request, @@ -148,13 +158,12 @@ public function testSetConfigOverridesAudienceValidationPass() $this->assertTrue(true); } - public function testSetConfigOverridesAudienceValidationFail() + public function testSetConfigOverridesAudienceValidationFail(): void { $request = m::mock(Request::class); $request->shouldReceive('session') ->andReturn($session = m::mock(SessionContract::class)); - $session->allows('has')->with('nonce')->andReturns(true); - $session->allows('get')->with('nonce')->andReturns('test-nonce'); + $session->expects('pull')->with('nonce')->andReturns('test-nonce'); $provider = new OpenIdTestProviderStub( $request, @@ -177,7 +186,244 @@ public function testSetConfigOverridesAudienceValidationFail() ]); } - public function testOidcJwksRefreshesWhenTokenKidIsMissingFromCachedKeys() + public function testTrustedAudiencesAcceptScalarConfigurationAndIgnoreAzp(): void + { + $request = m::mock(Request::class); + $request->expects('session')->andReturn($session = m::mock(SessionContract::class)); + $session->expects('pull')->with('nonce')->andReturn('nonce'); + + $provider = new OpenIdTestProviderStub($request, 'client_id', 'client_secret', 'redirect'); + $provider->withConfig(['trusted_audiences' => 'trusted-api']); + $provider->http = m::mock(Client::class); + $provider->http->expects('get') + ->with('http://base.url/.well-known/openid-configuration') + ->andReturn(new Response(body: json_encode(['issuer' => 'http://base.url']))); + + $provider->validateProviderPayload([ + 'nonce' => 'nonce', + 'aud' => ['client_id', 'trusted-api'], + 'azp' => 'another-party', + 'iss' => 'http://base.url', + ]); + + $this->addToAssertionCount(1); + } + + public function testAudienceRejectsUntrustedAndNonStringEntries(): void + { + $request = m::mock(Request::class); + $request->expects('session')->andReturn($session = m::mock(SessionContract::class)); + $session->expects('pull')->with('nonce')->andReturn('nonce'); + + $provider = new OpenIdTestProviderStub($request, 'client_id', 'client_secret', 'redirect'); + + $this->expectException(InvalidAudienceException::class); + + $provider->validateProviderPayload([ + 'nonce' => 'nonce', + 'aud' => ['client_id', 123], + 'iss' => 'http://base.url', + ]); + } + + public function testNonceIsConsumedAfterOneValidation(): void + { + $request = m::mock(Request::class); + $request->expects('session')->twice()->andReturn($session = m::mock(SessionContract::class)); + $session->expects('pull')->with('nonce')->twice()->andReturn('nonce', null); + + $provider = new OpenIdTestProviderStub($request, 'client_id', 'client_secret', 'redirect'); + $provider->http = m::mock(Client::class); + $provider->http->expects('get') + ->with('http://base.url/.well-known/openid-configuration') + ->andReturn(new Response(body: json_encode(['issuer' => 'http://base.url']))); + + $payload = [ + 'nonce' => 'nonce', + 'aud' => 'client_id', + 'iss' => 'http://base.url', + ]; + + $provider->validateProviderPayload($payload); + + $this->expectException(InvalidNonceException::class); + + $provider->validateProviderPayload($payload); + } + + public function testUserInfoUsesBearerAuthorizationWithoutTokenQuery(): void + { + $provider = new OpenIdTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->http = m::mock(Client::class); + $provider->http->expects('get') + ->with('http://base.url/.well-known/openid-configuration') + ->andReturn(new Response(body: json_encode([ + 'userinfo_endpoint' => 'http://userinfo.url', + ]))); + $provider->http->expects('get')->with('http://userinfo.url', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer access-token', + ], + ])->andReturn(new Response(body: '{"sub":"foo"}')); + + $this->assertSame(['sub' => 'foo'], $provider->getProviderUserByToken('access-token')); + } + + public function testMissingIdTokenFailsAtTheRequiredResponseBoundary(): void + { + $provider = new OpenIdTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + + $warning = null; + set_error_handler(function (int $severity, string $message) use (&$warning): bool { + if ($severity !== E_WARNING) { + return false; + } + + $warning = $message; + + return true; + }); + + try { + $provider->getProviderUserByTokenResponse(['access_token' => 'access-token']); + $this->fail('Expected the missing ID token to fail.'); + } catch (TypeError) { + $this->assertSame('Undefined array key "id_token"', $warning); + } finally { + restore_error_handler(); + } + } + + public function testDiscoveryCacheIsKeyedByTheExactConfigurationUrl(): void + { + $provider = new OpenIdTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->http = m::mock(Client::class); + $provider->http->expects('get') + ->with('https://tenant-a.example/.well-known/openid-configuration') + ->once() + ->andReturn(new Response(body: '{"issuer":"tenant-a"}')); + $provider->http->expects('get') + ->with('https://tenant-b.example/.well-known/openid-configuration') + ->once() + ->andReturn(new Response(body: '{"issuer":"tenant-b"}')); + + $provider->setConfig(['base_url' => 'https://tenant-a.example']); + $this->assertSame('tenant-a', $provider->getProviderOpenIdConfig()['issuer']); + $this->assertSame('tenant-a', $provider->getProviderOpenIdConfig()['issuer']); + + $provider->setConfig(['base_url' => 'https://tenant-b.example']); + $this->assertSame('tenant-b', $provider->getProviderOpenIdConfig()['issuer']); + } + + public function testDiscoveryRefreshReachesTheNetwork(): void + { + $provider = new OpenIdTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->http = m::mock(Client::class); + $provider->http->expects('get') + ->with('http://base.url/.well-known/openid-configuration') + ->twice() + ->andReturn( + new Response(body: '{"issuer":"first"}'), + new Response(body: '{"issuer":"refreshed"}'), + ); + + $this->assertSame('first', $provider->getProviderOpenIdConfig()['issuer']); + $this->assertSame('first', $provider->getProviderOpenIdConfig()['issuer']); + $this->assertSame('refreshed', $provider->getProviderOpenIdConfig(refresh: true)['issuer']); + } + + public function testFailedDiscoveryDoesNotReplaceThePreviousExactUrlEntry(): void + { + $provider = new OpenIdTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->http = m::mock(Client::class); + $provider->http->expects('get') + ->with('https://tenant-a.example/.well-known/openid-configuration') + ->once() + ->andReturn(new Response(body: '{"issuer":"tenant-a"}')); + $provider->http->expects('get') + ->with('https://tenant-b.example/.well-known/openid-configuration') + ->once() + ->andThrow($failure = new RuntimeException('Discovery unavailable.')); + + $provider->setConfig(['base_url' => 'https://tenant-a.example']); + $this->assertSame('tenant-a', $provider->getProviderOpenIdConfig()['issuer']); + + $provider->setConfig(['base_url' => 'https://tenant-b.example']); + + try { + $provider->getProviderOpenIdConfig(); + $this->fail('Expected discovery to fail.'); + } catch (ConfigurationFetchingException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $provider->setConfig(['base_url' => 'https://tenant-a.example']); + $this->assertSame('tenant-a', $provider->getProviderOpenIdConfig()['issuer']); + } + + public function testDiscoveryRejectsJsonWithoutNamedFields(): void + { + $provider = new OpenIdTestProviderStub( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect' + ); + $provider->http = m::mock(Client::class); + $provider->http->expects('get')->andReturn(new Response(body: '{}')); + + try { + $provider->getProviderOpenIdConfig(); + $this->fail('Expected discovery metadata to be rejected.'); + } catch (ConfigurationFetchingException $exception) { + $this->assertInstanceOf(UnexpectedValueException::class, $exception->getPrevious()); + } + } + + public function testOidcOperationalExceptionsUseRuntimeTaxonomy(): void + { + $this->assertInstanceOf(RuntimeException::class, new ConfigurationFetchingException); + $this->assertInstanceOf(RuntimeException::class, new InvalidUserInfoUrlException); + } + + public function testOidcValidationDoesNotRequireNonceWhenDisabled(): void + { + $key = $this->createRsaKeyPair('nonce-disabled-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [$this->jwks($key)]); + + $this->assertSame('foo', $provider->verifyToken($this->createSignedToken($key))['sub']); + } + + public function testOidcJwksRefreshesWhenTokenKidIsMissingFromCachedKeys(): void { $oldKey = $this->createRsaKeyPair('old-key'); $newKey = $this->createRsaKeyPair('new-key'); @@ -194,7 +440,7 @@ public function testOidcJwksRefreshesWhenTokenKidIsMissingFromCachedKeys() $this->assertSame('foo', $user['sub']); } - public function testOidcJwksRemainCachedWhenTokenKidIsPresent() + public function testOidcJwksRemainCachedWhenTokenKidIsPresent(): void { $key = $this->createRsaKeyPair('current-key'); $provider = $this->createVerifyingProvider(); @@ -211,7 +457,7 @@ public function testOidcJwksRemainCachedWhenTokenKidIsPresent() $this->assertSame('foo', $secondUser['sub']); } - public function testOidcJwksDoesNotRefreshForTokenWithoutKid() + public function testOidcJwksDoesNotRefreshForTokenWithoutKid(): void { $key = $this->createRsaKeyPair('current-key'); $provider = $this->createVerifyingProvider(); @@ -227,7 +473,7 @@ public function testOidcJwksDoesNotRefreshForTokenWithoutKid() $provider->verifyToken($this->createSignedToken($key, includeKid: false)); } - public function testOidcJwksRefreshCooldownPreventsRepeatedUnknownKidFetches() + public function testOidcJwksRefreshCooldownPreventsRepeatedUnknownKidFetches(): void { $oldKey = $this->createRsaKeyPair('old-key'); $newKey = $this->createRsaKeyPair('new-key'); @@ -255,7 +501,7 @@ public function testOidcJwksRefreshCooldownPreventsRepeatedUnknownKidFetches() $this->assertSame(2, $failures); } - public function testOidcJwksRefreshesWhenCachedKeyMaterialIsStale() + public function testOidcJwksRefreshesWhenCachedKeyMaterialIsStale(): void { $oldKey = $this->createRsaKeyPair('shared-key'); $newKey = $this->createRsaKeyPair('shared-key'); @@ -272,13 +518,241 @@ public function testOidcJwksRefreshesWhenCachedKeyMaterialIsStale() $this->assertSame('foo', $user['sub']); } + public function testOidcJwksHonorsZeroPaddedMaxAgeAndRefetchesWhenImmediatelyStale(): void + { + $key = $this->createRsaKeyPair('cache-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + new Response(headers: ['Cache-Control' => 'max-age=0000'], body: json_encode($this->jwks($key))), + new Response(headers: ['Cache-Control' => 'max-age=60'], body: json_encode($this->jwks($key))), + ]); + + $this->assertSame('foo', $provider->verifyToken($this->createSignedToken($key))['sub']); + $this->assertSame('foo', $provider->verifyToken($this->createSignedToken($key))['sub']); + } + + #[DataProvider('immediateStalenessDirectiveProvider')] + public function testOidcJwksNoCacheDirectivesWinAcrossRepeatedHeaders(string $directive): void + { + $key = $this->createRsaKeyPair('no-cache-key-' . strtolower($directive)); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + new Response(headers: [ + 'Cache-Control' => ['max-age=600', 'private, ' . $directive], + ], body: json_encode($this->jwks($key))), + $this->jwks($key), + ]); + + $token = $this->createSignedToken($key); + + $provider->verifyToken($token); + $provider->verifyToken($token); + + $this->addToAssertionCount(1); + } + + public static function immediateStalenessDirectiveProvider(): array + { + return [ + 'no-cache' => ['NO-CACHE'], + 'no-store' => ['no-store'], + ]; + } + + public function testOidcJwksUsesTheSmallestRepeatedMaxAge(): void + { + $key = $this->createRsaKeyPair('repeated-max-age'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + new Response(headers: [ + 'Cache-Control' => ['max-age=600', 'private, MAX-AGE=0'], + ], body: json_encode($this->jwks($key))), + $this->jwks($key), + ]); + + $token = $this->createSignedToken($key); + + $provider->verifyToken($token); + $provider->verifyToken($token); + + $this->addToAssertionCount(1); + } + + public function testOidcJwksIgnoresMalformedAndOverflowingMaxAgeValues(): void + { + $key = $this->createRsaKeyPair('malformed-max-age'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + new Response(headers: [ + 'Cache-Control' => 'max-age=-1, max-age=invalid, max-age=' . PHP_INT_MAX . '0', + ], body: json_encode($this->jwks($key))), + ]); + + $token = $this->createSignedToken($key); + + $provider->verifyToken($token); + $provider->verifyToken($token); + + $this->addToAssertionCount(1); + } + + public function testOidcJwksSwitchesWithTheExactDiscoveryUrl(): void + { + $tenantAKey = $this->createRsaKeyPair('tenant-a-key'); + $tenantBKey = $this->createRsaKeyPair('tenant-b-key'); + $provider = $this->createVerifyingProvider(); + + $provider->http->expects('get') + ->with('https://tenant-a.example/.well-known/openid-configuration') + ->once() + ->andReturn(new Response(body: json_encode([ + 'issuer' => 'tenant-a', + 'jwks_uri' => 'https://tenant-a.example/jwks', + ]))); + $provider->http->expects('get') + ->with('https://tenant-b.example/.well-known/openid-configuration') + ->once() + ->andReturn(new Response(body: json_encode([ + 'issuer' => 'tenant-b', + 'jwks_uri' => 'https://tenant-b.example/jwks', + ]))); + $provider->http->expects('get') + ->with('https://tenant-a.example/jwks') + ->once() + ->andReturn(new Response(body: json_encode($this->jwks($tenantAKey)))); + $provider->http->expects('get') + ->with('https://tenant-b.example/jwks') + ->once() + ->andReturn(new Response(body: json_encode($this->jwks($tenantBKey)))); + + $provider->setConfig(['base_url' => 'https://tenant-a.example']); + $this->assertSame('foo', $provider->verifyToken($this->createSignedToken($tenantAKey, issuer: 'tenant-a'))['sub']); + + $provider->setConfig(['base_url' => 'https://tenant-b.example']); + $this->assertSame('foo', $provider->verifyToken($this->createSignedToken($tenantBKey, issuer: 'tenant-b'))['sub']); + } + + public function testOidcColdJwksFailureRetriesOnTheNextLogin(): void + { + $key = $this->createRsaKeyPair('cold-retry-key'); + $provider = $this->createVerifyingProvider(); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $attempt = 0; + $provider->http->expects('get') + ->with('http://jwks.url') + ->twice() + ->andReturnUsing(function () use (&$attempt, $key): Response { + if ($attempt++ === 0) { + throw new RuntimeException('JWKS unavailable.'); + } + + return new Response(body: json_encode($this->jwks($key))); + }); + + try { + $provider->verifyToken($this->createSignedToken($key)); + $this->fail('Expected the first JWKS request to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('JWKS unavailable.', $exception->getMessage()); + } + + $this->assertSame('foo', $provider->verifyToken($this->createSignedToken($key))['sub']); + } + + public function testOidcFailedForcedRefreshIsThrottledWhenCachedKeysRemain(): void + { + $oldKey = $this->createRsaKeyPair('throttled-old-key'); + $newKey = $this->createRsaKeyPair('throttled-new-key'); + $provider = $this->createVerifyingProvider(); + $provider->setJwksRefreshCooldownSeconds(60); + + $this->expectOpenIdConfigRequests($provider->http, 2); + $attempt = 0; + $provider->http->expects('get') + ->with('http://jwks.url') + ->twice() + ->andReturnUsing(function () use (&$attempt, $oldKey): Response { + if ($attempt++ === 0) { + return new Response(body: json_encode($this->jwks($oldKey))); + } + + throw new RuntimeException('Refresh failed.'); + }); + + $token = $this->createSignedToken($newKey); + + try { + $provider->verifyToken($token); + $this->fail('Expected the forced refresh to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Refresh failed.', $exception->getMessage()); + } + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('"kid" invalid'); + + $provider->verifyToken($token); + } + + public function testOidcChangedJwksUrlFailureRetriesWithoutPublishingPartialState(): void + { + $oldKey = $this->createRsaKeyPair('changed-url-old-key'); + $newKey = $this->createRsaKeyPair('changed-url-new-key'); + $provider = $this->createVerifyingProvider(); + + $provider->http->expects('get') + ->with('http://base.url/.well-known/openid-configuration') + ->twice() + ->andReturn( + new Response(body: json_encode([ + 'issuer' => 'http://base.url', + 'jwks_uri' => 'http://old-jwks.url', + ])), + new Response(body: json_encode([ + 'issuer' => 'http://base.url', + 'jwks_uri' => 'http://new-jwks.url', + ])), + ); + $provider->http->expects('get') + ->with('http://old-jwks.url') + ->once() + ->andReturn(new Response(body: json_encode($this->jwks($oldKey)))); + $newJwksAttempt = 0; + $provider->http->expects('get') + ->with('http://new-jwks.url') + ->twice() + ->andReturnUsing(function () use (&$newJwksAttempt, $newKey): Response { + if ($newJwksAttempt++ === 0) { + throw new RuntimeException('New JWKS unavailable.'); + } + + return new Response(body: json_encode($this->jwks($newKey))); + }); + + $token = $this->createSignedToken($newKey); + + try { + $provider->verifyToken($token); + $this->fail('Expected the changed JWKS URL to fail once.'); + } catch (RuntimeException $exception) { + $this->assertSame('New JWKS unavailable.', $exception->getMessage()); + } + + $this->assertSame('foo', $provider->verifyToken($token)['sub']); + } + private function createVerifyingProvider(): VerifyingOpenIdTestProviderStub { $request = m::mock(Request::class); - $request->shouldReceive('session') - ->andReturn($session = m::mock(SessionContract::class)); - $session->allows('has')->with('nonce')->andReturns(true); - $session->allows('get')->with('nonce')->andReturns('nonce'); $provider = new VerifyingOpenIdTestProviderStub( $request, @@ -311,18 +785,22 @@ private function expectJwksRequests(Client $http, array $jwksResponses): void ->with('http://jwks.url') ->times(count($jwksResponses)) ->andReturn(...array_map( - fn (array $jwks): Response => new Response(body: json_encode($jwks)), + fn (array|Response $response): Response => $response instanceof Response + ? $response + : new Response(body: json_encode($response)), $jwksResponses )); } - private function createSignedToken(array $key, bool $includeKid = true): string - { + private function createSignedToken( + array $key, + bool $includeKid = true, + string $issuer = 'http://base.url', + ): string { return JWT::encode([ - 'iss' => 'http://base.url', + 'iss' => $issuer, 'sub' => 'foo', 'aud' => 'client_id', - 'nonce' => 'nonce', 'iat' => time(), 'exp' => time() + 3600, ], $key['private'], 'RS256', $includeKid ? $key['kid'] : null); diff --git a/tests/Socialite/PackageMetadataTest.php b/tests/Socialite/PackageMetadataTest.php new file mode 100644 index 000000000..334113b62 --- /dev/null +++ b/tests/Socialite/PackageMetadataTest.php @@ -0,0 +1,57 @@ +assertArrayHasKey('firebase/php-jwt', $composer['require']); + $this->assertArrayHasKey('firebase/php-jwt', $rootComposer['require']); + $this->assertSame( + $rootComposer['require']['firebase/php-jwt'], + $composer['require']['firebase/php-jwt'], + ); + $this->assertArrayNotHasKey('phpseclib/phpseclib', $composer['require']); + $this->assertArrayNotHasKey('phpseclib/phpseclib', $rootComposer['require']); + } + + public function testFacadeDocumentsOnlyTheManagerSurface(): void + { + $docblock = (new ReflectionClass(Socialite::class))->getDocComment(); + $this->assertIsString($docblock); + + foreach (['with', 'driver', 'buildOAuth2Provider', 'extend', 'forgetDrivers'] as $method) { + $this->assertStringContainsString(" {$method}(", $docblock); + } + + foreach (['formatConfig', 'redirect', 'user', 'userFromToken', 'refreshToken'] as $method) { + $this->assertStringNotContainsString(" {$method}(", $docblock); + } + } +} From 334336bcd856c7e30ca2c910b570ac944d45cc42 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:38:02 +0000 Subject: [PATCH 07/14] socialite: secure provider transport and diagnostics Send Bitbucket, GitLab, and generic provider credentials through their correct Bearer transport, update GitLab user lookup to the current endpoint, and make LinkedIn image mapping tolerate missing optional nodes. Mark secret-bearing provider frames with SensitiveParameter while keeping non-secret diagnostics visible. Add exact request-shape, optional-image, and derived provider-surface reflection coverage so new first-party providers inherit the same security contract. --- src/socialite/src/Two/BitbucketProvider.php | 15 ++-- src/socialite/src/Two/GithubProvider.php | 7 +- src/socialite/src/Two/GitlabProvider.php | 7 +- .../src/Two/LinkedInOpenIdProvider.php | 5 +- src/socialite/src/Two/LinkedInProvider.php | 23 +++-- src/socialite/src/Two/SlackOpenIdProvider.php | 3 +- src/socialite/src/Two/SlackProvider.php | 5 +- src/socialite/src/Two/XProvider.php | 7 +- tests/Socialite/BitbucketProviderTest.php | 52 +++++++++++ tests/Socialite/GitlabProviderTest.php | 43 +++++++++ .../Socialite/LinkedInOpenIdProviderTest.php | 4 +- tests/Socialite/LinkedInProviderTest.php | 48 +++++++++- tests/Socialite/SensitiveParameterTest.php | 88 +++++++++++++++++++ tests/Socialite/SlackOpenIdProviderTest.php | 4 +- tests/Socialite/SlackProviderTest.php | 4 +- 15 files changed, 279 insertions(+), 36 deletions(-) create mode 100644 tests/Socialite/BitbucketProviderTest.php create mode 100644 tests/Socialite/GitlabProviderTest.php create mode 100644 tests/Socialite/SensitiveParameterTest.php diff --git a/src/socialite/src/Two/BitbucketProvider.php b/src/socialite/src/Two/BitbucketProvider.php index edc70301d..36daf1f69 100644 --- a/src/socialite/src/Two/BitbucketProvider.php +++ b/src/socialite/src/Two/BitbucketProvider.php @@ -7,6 +7,7 @@ use Exception; use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class BitbucketProvider extends AbstractProvider implements ProviderInterface { @@ -30,10 +31,10 @@ protected function getTokenUrl(): string return 'https://bitbucket.org/site/oauth2/access_token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get('https://api.bitbucket.org/2.0/user', [ - RequestOptions::QUERY => ['access_token' => $token], + RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], ]); $user = json_decode((string) $response->getBody(), true); @@ -48,12 +49,12 @@ protected function getUserByToken(string $token): array /** * Get the email for the given access token. */ - protected function getEmailByToken(string $token): ?string + protected function getEmailByToken(#[SensitiveParameter] string $token): ?string { - $emailsUrl = 'https://api.bitbucket.org/2.0/user/emails?access_token=' . $token; - try { - $response = $this->getHttpClient()->get($emailsUrl); + $response = $this->getHttpClient()->get('https://api.bitbucket.org/2.0/user/emails', [ + RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], + ]); } catch (Exception $e) { return null; } @@ -83,7 +84,7 @@ protected function mapUserToObject(array $user): User /** * Get the access token for the given code. */ - public function getAccessToken(string $code): string + public function getAccessToken(#[SensitiveParameter] string $code): string { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::AUTH => [$this->getClientId(), $this->getClientSecret()], diff --git a/src/socialite/src/Two/GithubProvider.php b/src/socialite/src/Two/GithubProvider.php index 38deb1906..ed9408717 100644 --- a/src/socialite/src/Two/GithubProvider.php +++ b/src/socialite/src/Two/GithubProvider.php @@ -7,6 +7,7 @@ use Exception; use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class GithubProvider extends AbstractProvider implements ProviderInterface { @@ -25,7 +26,7 @@ protected function getTokenUrl(): string return 'https://github.com/login/oauth/access_token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $userUrl = 'https://api.github.com/user'; @@ -46,7 +47,7 @@ protected function getUserByToken(string $token): array /** * Get the email for the given access token. */ - protected function getEmailByToken(string $token): ?string + protected function getEmailByToken(#[SensitiveParameter] string $token): ?string { $emailsUrl = 'https://api.github.com/user/emails'; @@ -83,7 +84,7 @@ protected function mapUserToObject(array $user): User /** * Get the default options for an HTTP request. */ - protected function getRequestOptions(string $token): array + protected function getRequestOptions(#[SensitiveParameter] string $token): array { return [ RequestOptions::HEADERS => [ diff --git a/src/socialite/src/Two/GitlabProvider.php b/src/socialite/src/Two/GitlabProvider.php index f8d9fa891..288d9507e 100644 --- a/src/socialite/src/Two/GitlabProvider.php +++ b/src/socialite/src/Two/GitlabProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Socialite\Two; use GuzzleHttp\RequestOptions; +use SensitiveParameter; class GitlabProvider extends AbstractProvider implements ProviderInterface { @@ -45,10 +46,10 @@ protected function getTokenUrl(): string return $this->getHost() . '/oauth/token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { - $response = $this->getHttpClient()->get($this->getHost() . '/api/v3/user', [ - RequestOptions::QUERY => ['access_token' => $token], + $response = $this->getHttpClient()->get($this->getHost() . '/api/v4/user', [ + RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], ]); return json_decode((string) $response->getBody(), true); diff --git a/src/socialite/src/Two/LinkedInOpenIdProvider.php b/src/socialite/src/Two/LinkedInOpenIdProvider.php index 011a35f0f..fd5bc0f3d 100644 --- a/src/socialite/src/Two/LinkedInOpenIdProvider.php +++ b/src/socialite/src/Two/LinkedInOpenIdProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Socialite\Two; use GuzzleHttp\RequestOptions; +use SensitiveParameter; class LinkedInOpenIdProvider extends AbstractProvider implements ProviderInterface { @@ -28,7 +29,7 @@ protected function getTokenUrl(): string return 'https://www.linkedin.com/oauth/v2/accessToken'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { return $this->getBasicProfile($token); } @@ -36,7 +37,7 @@ protected function getUserByToken(string $token): array /** * Get the basic profile fields for the user. */ - protected function getBasicProfile(string $token): array + protected function getBasicProfile(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get('https://api.linkedin.com/v2/userinfo', [ RequestOptions::HEADERS => [ diff --git a/src/socialite/src/Two/LinkedInProvider.php b/src/socialite/src/Two/LinkedInProvider.php index 132de702e..7fe137841 100644 --- a/src/socialite/src/Two/LinkedInProvider.php +++ b/src/socialite/src/Two/LinkedInProvider.php @@ -6,6 +6,7 @@ use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class LinkedInProvider extends AbstractProvider implements ProviderInterface { @@ -29,7 +30,7 @@ protected function getTokenUrl(): string return 'https://www.linkedin.com/oauth/v2/accessToken'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $basicProfile = $this->getBasicProfile($token); $emailAddress = $this->getEmailAddress($token); @@ -40,11 +41,11 @@ protected function getUserByToken(string $token): array /** * Get the basic profile fields for the user. */ - protected function getBasicProfile(string $token): array + protected function getBasicProfile(#[SensitiveParameter] string $token): array { $fields = ['id', 'firstName', 'lastName', 'profilePicture(displayImage~:playableStreams)']; - if (in_array('r_liteprofile', $this->getScopes())) { + if (in_array('r_liteprofile', $this->getScopes(), true)) { array_push($fields, 'vanityName'); } @@ -64,7 +65,7 @@ protected function getBasicProfile(string $token): array /** * Get the email address for the user. */ - protected function getEmailAddress(string $token): array + protected function getEmailAddress(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get('https://api.linkedin.com/v2/emailAddress', [ RequestOptions::HEADERS => [ @@ -88,15 +89,21 @@ protected function mapUserToObject(array $user): User $images = (array) Arr::get($user, 'profilePicture.displayImage~.elements', []); $avatar = Arr::first($images, function ($image) { + $stillImage = $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage'] ?? []; + return ( - $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width'] - ?? $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['displaySize']['width'] + $stillImage['storageSize']['width'] + ?? $stillImage['displaySize']['width'] + ?? null ) === 100; }); $originalAvatar = Arr::first($images, function ($image) { + $stillImage = $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage'] ?? []; + return ( - $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['storageSize']['width'] - ?? $image['data']['com.linkedin.digitalmedia.mediaartifact.StillImage']['displaySize']['width'] + $stillImage['storageSize']['width'] + ?? $stillImage['displaySize']['width'] + ?? null ) === 800; }); diff --git a/src/socialite/src/Two/SlackOpenIdProvider.php b/src/socialite/src/Two/SlackOpenIdProvider.php index 9cd7e13c3..72e91415e 100644 --- a/src/socialite/src/Two/SlackOpenIdProvider.php +++ b/src/socialite/src/Two/SlackOpenIdProvider.php @@ -6,6 +6,7 @@ use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class SlackOpenIdProvider extends AbstractProvider implements ProviderInterface { @@ -29,7 +30,7 @@ protected function getTokenUrl(): string return 'https://slack.com/api/openid.connect.token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get('https://slack.com/api/openid.connect.userInfo', [ RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], diff --git a/src/socialite/src/Two/SlackProvider.php b/src/socialite/src/Two/SlackProvider.php index a2cd5920b..ffd75705d 100644 --- a/src/socialite/src/Two/SlackProvider.php +++ b/src/socialite/src/Two/SlackProvider.php @@ -6,6 +6,7 @@ use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class SlackProvider extends AbstractProvider implements ProviderInterface { @@ -57,7 +58,7 @@ protected function getTokenUrl(): string return 'https://slack.com/api/oauth.v2.access'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get('https://slack.com/api/users.identity', [ RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], @@ -89,7 +90,7 @@ protected function getCodeFields(?string $state = null): array return $fields; } - public function getAccessTokenResponse(string $code): array + public function getAccessTokenResponse(#[SensitiveParameter] string $code): array { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::HEADERS => $this->getTokenHeaders($code), diff --git a/src/socialite/src/Two/XProvider.php b/src/socialite/src/Two/XProvider.php index 139639886..6abc011a2 100644 --- a/src/socialite/src/Two/XProvider.php +++ b/src/socialite/src/Two/XProvider.php @@ -6,6 +6,7 @@ use GuzzleHttp\RequestOptions; use Hypervel\Support\Arr; +use SensitiveParameter; class XProvider extends AbstractProvider implements ProviderInterface { @@ -39,7 +40,7 @@ protected function getTokenUrl(): string return 'https://api.x.com/2/oauth2/token'; } - protected function getUserByToken(string $token): array + protected function getUserByToken(#[SensitiveParameter] string $token): array { $response = $this->getHttpClient()->get('https://api.x.com/2/users/me', [ RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], @@ -60,7 +61,7 @@ protected function mapUserToObject(array $user): User ]); } - public function getAccessTokenResponse(string $code): array + public function getAccessTokenResponse(#[SensitiveParameter] string $code): array { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::HEADERS => ['Accept' => 'application/json'], @@ -71,7 +72,7 @@ public function getAccessTokenResponse(string $code): array return json_decode((string) $response->getBody(), true); } - protected function getRefreshTokenResponse(string $refreshToken): array + protected function getRefreshTokenResponse(#[SensitiveParameter] string $refreshToken): array { $response = $this->getHttpClient()->post($this->getTokenUrl(), [ RequestOptions::HEADERS => ['Accept' => 'application/json'], diff --git a/tests/Socialite/BitbucketProviderTest.php b/tests/Socialite/BitbucketProviderTest.php new file mode 100644 index 000000000..968a3ba44 --- /dev/null +++ b/tests/Socialite/BitbucketProviderTest.php @@ -0,0 +1,52 @@ +setHttpClient($httpClient); + + $httpClient->expects('get')->with('https://api.bitbucket.org/2.0/user', [ + RequestOptions::HEADERS => ['Authorization' => 'Bearer access-token'], + ])->andReturn(new Response(body: json_encode([ + 'uuid' => 'user-id', + 'username' => 'taylor', + 'display_name' => 'Taylor Otwell', + 'links' => ['avatar' => ['href' => 'https://example.com/avatar.jpg']], + ]))); + $httpClient->expects('get')->with('https://api.bitbucket.org/2.0/user/emails', [ + RequestOptions::HEADERS => ['Authorization' => 'Bearer access-token'], + ])->andReturn(new Response(body: json_encode([ + 'values' => [[ + 'type' => 'email', + 'is_primary' => true, + 'is_confirmed' => true, + 'email' => 'taylor@example.com', + ]], + ]))); + + $user = $provider->userFromToken('access-token'); + + $this->assertSame('user-id', $user->getId()); + $this->assertSame('taylor@example.com', $user->getEmail()); + } +} diff --git a/tests/Socialite/GitlabProviderTest.php b/tests/Socialite/GitlabProviderTest.php new file mode 100644 index 000000000..6840138de --- /dev/null +++ b/tests/Socialite/GitlabProviderTest.php @@ -0,0 +1,43 @@ +setHttpClient($httpClient); + + $httpClient->expects('get')->with('https://gitlab.com/api/v4/user', [ + RequestOptions::HEADERS => ['Authorization' => 'Bearer access-token'], + ])->andReturn(new Response(body: json_encode([ + 'id' => 1, + 'username' => 'taylor', + 'name' => 'Taylor Otwell', + 'email' => 'taylor@example.com', + 'avatar_url' => 'https://example.com/avatar.jpg', + ]))); + + $user = $provider->userFromToken('access-token'); + + $this->assertSame(1, $user->getId()); + $this->assertSame('taylor@example.com', $user->getEmail()); + } +} diff --git a/tests/Socialite/LinkedInOpenIdProviderTest.php b/tests/Socialite/LinkedInOpenIdProviderTest.php index 6cb6e13fd..ed4a91825 100644 --- a/tests/Socialite/LinkedInOpenIdProviderTest.php +++ b/tests/Socialite/LinkedInOpenIdProviderTest.php @@ -17,7 +17,7 @@ class LinkedInOpenIdProviderTest extends TestCase { - public function testResponse() + public function testResponse(): void { $user = $this->fromResponse([ 'sub' => 'asdfgh', @@ -49,7 +49,7 @@ public function testResponse() ], $user->attributes); } - public function testMissingEmailAndAvatar() + public function testMissingEmailAndAvatar(): void { $user = $this->fromResponse([ 'sub' => 'asdfgh', diff --git a/tests/Socialite/LinkedInProviderTest.php b/tests/Socialite/LinkedInProviderTest.php index 8c19b8670..2c8957954 100644 --- a/tests/Socialite/LinkedInProviderTest.php +++ b/tests/Socialite/LinkedInProviderTest.php @@ -13,10 +13,11 @@ use Mockery as m; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamInterface; +use ReflectionMethod; class LinkedInProviderTest extends TestCase { - public function testMapUserWithoutEmailAndAddress() + public function testMapUserWithoutEmailAndAddress(): void { $request = m::mock(Request::class); $request->allows('input')->with('code')->andReturns('fake-code'); @@ -77,4 +78,49 @@ public function testMapUserWithoutEmailAndAddress() $this->assertSame($userId, $user->getId()); $this->assertNull($user->getEmail()); } + + public function testMapUserSkipsImagesWithoutStillImageMetadata(): void + { + $provider = new LinkedInProvider( + m::mock(Request::class), + 'client_id', + 'client_secret', + 'redirect', + ); + $method = new ReflectionMethod($provider, 'mapUserToObject'); + + $image = fn (int $width, string $url): array => [ + 'data' => [ + 'com.linkedin.digitalmedia.mediaartifact.StillImage' => [ + 'storageSize' => ['width' => $width], + ], + ], + 'identifiers' => [['identifier' => $url]], + ]; + + $user = $method->invoke($provider, [ + 'id' => 1, + 'firstName' => [ + 'preferredLocale' => ['language' => 'en', 'country' => 'US'], + 'localized' => ['en_US' => 'Taylor'], + ], + 'lastName' => [ + 'preferredLocale' => ['language' => 'en', 'country' => 'US'], + 'localized' => ['en_US' => 'Otwell'], + ], + 'profilePicture' => [ + 'displayImage~' => [ + 'elements' => [ + ['data' => [], 'identifiers' => []], + $image(100, 'https://example.com/avatar.jpg'), + ['data' => [], 'identifiers' => []], + $image(800, 'https://example.com/avatar-original.jpg'), + ], + ], + ], + ]); + + $this->assertSame('https://example.com/avatar.jpg', $user->getAvatar()); + $this->assertSame('https://example.com/avatar-original.jpg', $user->avatar_original); + } } diff --git a/tests/Socialite/SensitiveParameterTest.php b/tests/Socialite/SensitiveParameterTest.php new file mode 100644 index 000000000..3abb147dc --- /dev/null +++ b/tests/Socialite/SensitiveParameterTest.php @@ -0,0 +1,88 @@ +getMethods() as $method) { + if ($method->getDeclaringClass()->getName() !== SocialiteManager::class + || preg_match('/^create.+Driver$/', $method->getName()) !== 1) { + continue; + } + + $returnType = $method->getReturnType(); + + if ($returnType instanceof ReflectionNamedType && ! $returnType->isBuiltin()) { + $classes[] = $returnType->getName(); + } + } + + $responseMethods = [ + 'fake', + 'getUserByTokenResponse', + 'parseAccessToken', + 'parseApprovedScopes', + 'parseExpiresIn', + 'parseRefreshToken', + 'setAccessTokenResponseBody', + 'userInstance', + ]; + $sensitiveNames = ['clientSecret', 'code', 'config', 'idToken', 'refreshToken', 'token']; + $checked = []; + $missing = []; + + foreach (array_unique($classes) as $class) { + foreach ((new ReflectionClass($class))->getMethods() as $method) { + foreach ($method->getParameters() as $parameter) { + if (! in_array($parameter->getName(), $sensitiveNames, true) + && ! (in_array($method->getName(), $responseMethods, true) + && in_array($parameter->getName(), ['accessTokenResponseBody', 'attributes', 'response'], true))) { + continue; + } + + $key = $method->getDeclaringClass()->getName() + . '::' . $method->getName() + . '($' . $parameter->getName() . ')'; + + if (isset($checked[$key])) { + continue; + } + + $checked[$key] = true; + + if ($parameter->getAttributes(SensitiveParameter::class) === []) { + $missing[] = $key; + } + } + } + } + + $this->assertNotEmpty($checked); + $this->assertSame([], $missing); + } +} diff --git a/tests/Socialite/SlackOpenIdProviderTest.php b/tests/Socialite/SlackOpenIdProviderTest.php index 7feb9bb03..03b5f4a38 100644 --- a/tests/Socialite/SlackOpenIdProviderTest.php +++ b/tests/Socialite/SlackOpenIdProviderTest.php @@ -17,7 +17,7 @@ class SlackOpenIdProviderTest extends TestCase { - public function testResponse() + public function testResponse(): void { $user = $this->fromResponse([ 'sub' => 'U1Q2W3E4R5T', @@ -46,7 +46,7 @@ public function testResponse() ], $user->attributes); } - public function testMissingEmailAndAvatar() + public function testMissingEmailAndAvatar(): void { $user = $this->fromResponse([ 'sub' => 'U1Q2W3E4R5T', diff --git a/tests/Socialite/SlackProviderTest.php b/tests/Socialite/SlackProviderTest.php index 74a2814e7..08ac603af 100644 --- a/tests/Socialite/SlackProviderTest.php +++ b/tests/Socialite/SlackProviderTest.php @@ -11,7 +11,7 @@ class SlackProviderTest extends TestCase { - public function testDefaultScopeKeyIsUserScope() + public function testDefaultScopeKeyIsUserScope(): void { $request = m::mock(Request::class); @@ -32,7 +32,7 @@ public function testDefaultScopeKeyIsUserScope() $this->assertArrayHasKey('user_scope', $query); } - public function testAsBotUserChangesScopeKey() + public function testAsBotUserChangesScopeKey(): void { $request = m::mock(Request::class); From bf18593ddd79b9e3cf30bc3122126addbd5b5dc8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:38:13 +0000 Subject: [PATCH 08/14] docs: explain Socialite extension and tenant flows Document custom OAuth 2 and OpenID Connect providers, protected response parsers, full token responses, request access, trusted audiences, testing fakes, and provider registration in Laravel-style prose. Clarify boot-time versus request-local configuration, require tenant configuration on both redirect and callback requests, explain session requirements for PKCE and nonce validation, and record the intentional OAuth 1 and legacy Twitter differences without exposing internal lifecycle machinery. --- src/boost/docs/socialite.md | 183 +++++++++++++++++++++++++++++++----- src/socialite/README.md | 12 ++- 2 files changed, 168 insertions(+), 27 deletions(-) diff --git a/src/boost/docs/socialite.md b/src/boost/docs/socialite.md index 2ef9fcbcb..9c9a19fe1 100644 --- a/src/boost/docs/socialite.md +++ b/src/boost/docs/socialite.md @@ -12,6 +12,8 @@ - [Dynamic Provider Configuration](#dynamic-provider-configuration) - [PKCE](#pkce) - [Custom Providers](#custom-providers) + - [OAuth 2.0 Providers](#custom-oauth2-providers) + - [OpenID Connect Providers](#custom-openid-connect-providers) - [Retrieving User Details](#retrieving-user-details) - [Retrieving User Details From a Token](#retrieving-user-details-from-a-token) - [Refreshing Access Tokens](#refreshing-access-tokens) @@ -196,15 +198,30 @@ return Socialite::driver('google') If your application resolves provider credentials at runtime, you may use the `setConfig` method to override the provider configuration for the current request: ```php +use App\Models\Tenant; use Hypervel\Socialite\Socialite; -return Socialite::driver('github') - ->setConfig([ - 'client_id' => $tenant->github_client_id, - 'client_secret' => $tenant->github_client_secret, - 'redirect' => route('github.callback', ['tenant' => $tenant]), - ]) - ->redirect(); +Route::get('/{tenant}/auth/github/redirect', function (Tenant $tenant) { + return Socialite::driver('github') + ->setConfig([ + 'client_id' => $tenant->github_client_id, + 'client_secret' => $tenant->github_client_secret, + 'redirect' => route('github.callback', ['tenant' => $tenant]), + ]) + ->redirect(); +})->name('github.redirect'); + +Route::get('/{tenant}/auth/github/callback', function (Tenant $tenant) { + $user = Socialite::driver('github') + ->setConfig([ + 'client_id' => $tenant->github_client_id, + 'client_secret' => $tenant->github_client_secret, + 'redirect' => route('github.callback', ['tenant' => $tenant]), + ]) + ->user(); + + // ... +})->name('github.callback'); ``` Partial overrides preserve the provider's base configuration. For example, you may override only the client ID while continuing to use the configured client secret and redirect URL: @@ -217,6 +234,8 @@ return Socialite::driver('github') The `setConfig` method stores the override in coroutine-local context, so it is safe to use on cached provider instances. OAuth 2.0 providers understand the `client_id`, `client_secret`, and `redirect` keys. Other keys are also available to custom providers through their provider configuration. +The redirect and callback are separate requests. If you use dynamic credentials, call `setConfig` in both routes so each request receives the same provider configuration. + If you only need to override the callback URL for the current request, you may use the `redirectUrl` method: ```php @@ -249,26 +268,129 @@ The `x` driver enables PKCE by default. ### Custom Providers -You may register custom providers using the `extend` method. For OAuth 2.0 providers, use the `buildOAuth2Provider` method to build a provider instance from your `config/services.php` configuration: +You may register custom providers using the `extend` method. Custom providers should be registered in the `boot` method of one of your application's service providers. + + +#### OAuth 2.0 Providers + +For OAuth 2.0 providers, extend `Hypervel\Socialite\Two\AbstractProvider` and implement the `Hypervel\Socialite\Two\ProviderInterface` contract. The provider must define its authorization and token endpoints, retrieve the user using an access token, and map the provider's response to a Socialite user: + +```php +namespace App\Socialite; + +use GuzzleHttp\RequestOptions; +use Hypervel\Socialite\Two\AbstractProvider; +use Hypervel\Socialite\Two\ProviderInterface; +use Hypervel\Socialite\Two\User; +use SensitiveParameter; + +class AcmeProvider extends AbstractProvider implements ProviderInterface +{ + protected function getAuthUrl(?string $state): string + { + return $this->buildAuthUrlFromBase('https://acme.example.com/oauth/authorize', $state); + } + + protected function getTokenUrl(): string + { + return 'https://acme.example.com/oauth/token'; + } + + protected function getUserByToken(#[SensitiveParameter] string $token): array + { + $response = $this->getHttpClient()->get('https://acme.example.com/api/user', [ + RequestOptions::HEADERS => ['Authorization' => 'Bearer ' . $token], + ]); + + return json_decode((string) $response->getBody(), true); + } + + protected function mapUserToObject(array $user): User + { + return (new User)->setRaw($user)->map([ + 'id' => $user['id'], + 'name' => $user['name'], + 'email' => $user['email'], + ]); + } +} +``` + +You may then use the `buildOAuth2Provider` method to build the provider from your `config/services.php` configuration: ```php use App\Socialite\AcmeProvider; use Hypervel\Contracts\Container\Container; use Hypervel\Socialite\Socialite; -Socialite::extend('acme', function (Container $app) { - return Socialite::buildOAuth2Provider( - AcmeProvider::class, - $app->make('config')->get('services.acme') - ); -}); +public function boot(): void +{ + Socialite::extend('acme', function (Container $app) { + return Socialite::buildOAuth2Provider( + AcmeProvider::class, + $app->make('config')->get('services.acme') + ); + }); +} +``` + +The `buildOAuth2Provider` method requires `client_id`, `client_secret`, and `redirect` configuration keys and will resolve relative redirect URLs to fully qualified URLs. Register custom drivers from a service provider's `boot` method. The manager and the provider's baseline configuration live for the worker lifetime, so `withConfig` is intended for this boot-time setup. Use `setConfig` for request-specific values. + +If the provider returns a different token response shape, you may override the protected `parseAccessToken`, `parseRefreshToken`, `parseExpiresIn`, and `parseApprovedScopes` methods. A provider that needs the entire response to retrieve the user may override `getUserByTokenResponse` instead of replacing the `user` method: + +```php +protected function getUserByTokenResponse(#[SensitiveParameter] array $response): array +{ + return $this->getUserByToken($response['credentials']['access_token']); +} ``` -The `buildOAuth2Provider` method requires `client_id`, `client_secret`, and `redirect` configuration keys and will resolve relative redirect URLs to fully qualified URLs. +The protected `getRequest` method returns the request for the current authentication flow. Use it when a provider needs request data beyond the authorization code. The returned `User` instance exposes the complete token response through its `accessTokenResponseBody` property. -If you need to adapt configuration for a custom OAuth 2.0 provider, the `formatConfig` method returns the provider configuration with `identifier`, `secret`, and `callback_uri` keys derived from `client_id`, `client_secret`, and `redirect`. + +#### OpenID Connect Providers -For non-OAuth2 federated login providers, extend `Hypervel\Socialite\AbstractProvider` and implement the `Hypervel\Socialite\Contracts\Provider` contract. Custom providers must provide `redirect` and `user` methods. The base provider includes coroutine-safe request handling, HTTP client handling, runtime configuration, stateless mode, and custom redirect parameters. When building a custom provider directly, call `withConfig` to seed the provider's baseline configuration: +For providers that publish OpenID Connect discovery metadata, extend `Hypervel\Socialite\Two\OpenIdProvider`. In addition to mapping the user response, your provider only needs to return the issuer's base URL: + +```php +use Hypervel\Socialite\Two\OpenIdProvider; +use Hypervel\Socialite\Two\ProviderInterface; +use Hypervel\Socialite\Two\User; + +class AcmeOpenIdProvider extends OpenIdProvider implements ProviderInterface +{ + protected function getBaseUrl(): string + { + return 'https://identity.acme.example.com'; + } + + protected function mapUserToObject(array $user): User + { + return (new User)->setRaw($user)->map([ + 'id' => $user['sub'], + 'name' => $user['name'] ?? null, + 'email' => $user['email'] ?? null, + ]); + } +} +``` + +The base provider discovers the authorization, token, UserInfo, and JSON Web Key Set endpoints. UserInfo requests use Bearer authorization, and signing keys are reused according to the provider's cache directives and refreshed once when a provider rotates them. + +An ID token must include the configured client ID in its audience. If the provider also includes audiences for your APIs or other trusted services, list them using the `trusted_audiences` configuration option: + +```php +'acme' => [ + 'client_id' => env('ACME_CLIENT_ID'), + 'client_secret' => env('ACME_CLIENT_SECRET'), + 'redirect' => '/auth/acme/callback', + 'trusted_audiences' => ['https://api.acme.example.com'], +], +``` + +Generic OpenID Connect providers validate a one-time nonce stored in the session. The redirect and callback requests must therefore use the same session. + +For other federated login protocols, extend `Hypervel\Socialite\AbstractProvider` and implement the `Hypervel\Socialite\Contracts\Provider` contract. Custom providers must provide `redirect` and `user` methods. The base provider includes coroutine-safe request handling, HTTP client handling, runtime configuration, stateless mode, and custom redirect parameters. When building a custom provider directly, call `withConfig` to seed the provider's baseline configuration: ```php use App\Socialite\SamlProvider; @@ -298,6 +420,7 @@ Route::get('/auth/callback', function () { $refreshToken = $user->refreshToken; $expiresIn = $user->expiresIn; $approvedScopes = $user->approvedScopes; + $accessTokenResponse = $user->accessTokenResponseBody; $user->getId(); $user->getNickname(); @@ -307,6 +430,8 @@ Route::get('/auth/callback', function () { }); ``` +The `accessTokenResponseBody` property contains the complete response returned by the token endpoint. It is empty when the user was retrieved directly using `userFromToken` because no token exchange occurred. + #### Retrieving User Details From a Token @@ -353,6 +478,8 @@ use Hypervel\Socialite\Socialite; return Socialite::driver('google')->stateless()->user(); ``` +The `stateless` method disables OAuth state validation only. The `x` driver's PKCE verifier and generic OpenID Connect nonce validation are stored in the session, so those flows must keep session continuity between their redirect and callback requests. + ## Testing @@ -378,14 +505,14 @@ test('user is redirected to github', function () { #### Faking the Callback -To test your application's callback route, you may invoke the `fake` method and provide a `User` instance that should be returned when your application requests the user's details from the provider. The `User` instance may be created using the `map` method: +To test your application's callback route, you may invoke the `fake` method and provide a `User` instance that should be returned when your application requests the user's details from the provider. The `User` instance may be created using the `fake` method: ```php use Hypervel\Socialite\Socialite; use Hypervel\Socialite\Two\User; test('user can login with github', function () { - Socialite::fake('github', (new User)->map([ + Socialite::fake('github', User::fake([ 'id' => 'github-123', 'name' => 'Jason Beggs', 'email' => 'jason@example.com', @@ -403,27 +530,31 @@ test('user can login with github', function () { }); ``` -If needed, you may manually specify token properties on the fake `User` instance: +By default, the `User` instance includes fake OAuth token values. If needed, you may override these values by passing additional attributes to the `fake` method: ```php -$fakeUser = (new User)->map([ +$fakeUser = User::fake([ 'id' => 'github-123', 'name' => 'Jason Beggs', 'email' => 'jason@example.com', -])->setToken('fake-token') - ->setRefreshToken('fake-refresh-token') - ->setExpiresIn(3600) - ->setApprovedScopes(['read', 'write']); + 'token' => 'fake-token', + 'refreshToken' => 'fake-refresh-token', + 'expiresIn' => 3600, + 'approvedScopes' => ['read', 'write'], + 'accessTokenResponseBody' => ['access_token' => 'fake-token'], +]); ``` You may also provide a closure to resolve the fake user when the provider's `user` method is called: ```php Socialite::fake('github', function () { - return (new User)->map([ + return User::fake([ 'id' => 'github-123', 'name' => 'Jason Beggs', 'email' => 'jason@example.com', ]); }); ``` + +The fake replaces the `Hypervel\Socialite\Contracts\Factory` binding used by the facade. Code that resolves `Hypervel\Socialite\SocialiteManager` directly continues to use the real manager, so application code should depend on the factory contract when it needs an injectable Socialite manager. diff --git a/src/socialite/README.md b/src/socialite/README.md index eb7479e3c..3d2b5643e 100644 --- a/src/socialite/README.md +++ b/src/socialite/README.md @@ -1,4 +1,14 @@ Socialite for Hypervel === -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/socialite) \ No newline at end of file +Documentation: https://hypervel.org/docs/socialite + +Differences From Laravel +--- + +- OAuth 1.0 and the legacy `twitter` drivers are not supported. Use the OAuth 2.0 `x` driver instead. +- Custom providers may use `buildOAuth2Provider()`, runtime `setConfig()` overrides, protected request access, token-response parsers, and generic OpenID Connect support. The OAuth 1-only `formatConfig()` method is not included. +- OpenID Connect providers may trust additional audiences through `trusted_audiences` while still requiring the configured client ID. +- `stateless()` disables OAuth state validation, but the `x` driver's PKCE flow and generic OpenID Connect nonce validation still require session continuity. + +Ported from: https://github.com/laravel/socialite From b77b43add6c7bafa859b2a76cb360265dee43047 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:38:20 +0000 Subject: [PATCH 09/14] docs: record the completed Socialite audit Mark Socialite complete in the package checklist and routing index, record all accepted and rejected findings, and capture the final lifecycle, security, performance, compatibility, and validation outcomes. Record Support, Object Pool, and Reverb corrections at their owning package entries, add their dependency-index routes, and retain the completed Sanctum records merged from the latest 0.4 branch. --- ...amework-coroutine-state-lifecycle-audit.md | 13 ++++--- ...-coroutine-state-lifecycle-audit-ledger.md | 37 ++++++++++++++++++- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 1969b17f8..50ec755ac 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** None. `sanctum` is complete; detail plan `2026-08-07-1302-sanctum-correctness-cache-settlement-and-current-parity.md`. -- **Ledger entries required for the active work:** None. The completed Sanctum work is recorded under `Complete Sanctum correctness, cache settlement, and current parity`, with its Database and Auth findings also recorded at their owning package entries. -- **Pending revalidation carried into the active work:** None. +- **Active package or work unit:** None. `socialite` is complete; detail plan `2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md`. +- **Ledger entries required for the active work:** None. The completed Socialite work is recorded under `Complete Socialite correctness, first-party extensibility, and lifecycle`, with its cross-package findings recorded at their owning package entries. +- **Pending revalidation carried into the active work:** None. Socialite revalidated `support-02` and completed `support-34`, `object-pool-04`, and `reverb-40` at their owning boundaries. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1053,7 +1053,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events`, `bus`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite`, `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite` (revalidation complete), `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | | `macroable-03` | `macroable` | `cookie`, `log`, and `notifications` (revalidation complete); later full `jwt` audit | `Complete Macroable callable and test-state handling`; finding `macroable-03` | | `auth-01` | `support`, `auth` | `auth` (revalidation complete) | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | @@ -1213,6 +1213,9 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `routing-26` | `routing` | `foundation` and `routing` (targeted correction complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `routing-26` | | `session-25` | `session` | `foundation` and `session` (targeted correction complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `session-25` | | `view-42` | `view` | `foundation` and `view` (revalidation complete) | `Complete Translation correctness, current parity, and worker lifecycles`; finding `view-42` | +| `support-34` | `support` | `support` and `socialite` (revalidation complete) | `Complete Socialite correctness, first-party extensibility, and lifecycle`; finding `support-34` | +| `object-pool-04` | `object-pool` | `object-pool` (targeted correction complete) | `Complete Socialite correctness, first-party extensibility, and lifecycle`; finding `object-pool-04` | +| `reverb-40` | `reverb` | `reverb` (targeted correction complete) | `Complete Socialite correctness, first-party extensibility, and lifecycle`; finding `reverb-40` | ## Package checklist @@ -1303,7 +1306,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen - [x] `view` - [x] `translation` - [x] `pagination` -- [ ] `socialite` +- [x] `socialite` - [x] `sanctum` - [ ] `fortify` - [ ] `passkeys` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index e306ddd26..c68da7083 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -484,6 +484,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `support-29` | Parity defect | Minor | High | `Str::isUrl()` rejects valid current-Laravel single-label hosts used by internal HTTP services | Port the current domain branch and revalidate Stringable, Mail URL attachments, and Validation's `url` rule | | `support-30` | Fake assertion type defect | Major | High | MailFake's shared assertion helper rejects count and address shapes accepted by its public methods | Describe the complete callable, count, and address domain and route integer counts explicitly | | `support-31` | Fake assertion type defect | Minor | High | NotificationFake promises arbitrary strings as callbacks even though non-callable strings fail at the delegated boundary | Narrow to callable or integer, regenerate facade metadata, and port the complete current upstream fake tests | +| `support-34` | Manager rebinding defect | Major | High | `Manager::setContainer()` replaces the container but retains the old configuration repository, so cached managers read stale configuration after application rebinding | Refresh both the container and configuration references in the shared manager owner | | `auth-01` | Defect | Major | High | Worker-cached SessionGuard and PasswordBroker instances share one mutable Timebox whose early-return state persists and races across authentication operations | Clone the configured Timebox for each of the five timed operations so every operation has isolated mutable timing state | - **Intentional omissions:** Laravel deferred-provider APIs remain omitted because their per-request bootstrap optimization conflicts with Hypervel's long-lived provider architecture; record the difference in Support's README and at the natural source boundary. `loadFactoriesFrom()` is directly deprecated by current Laravel and receives only a concise source `REMOVED:` marker. `Auth::routes()` exists solely for `laravel/ui`, which Hypervel does not integrate; retain the documented manual/Fortify route approach and record the omission in Auth's README plus the natural facade source location. Do not invent placeholder tests for omitted APIs. @@ -500,6 +501,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Later Mail revalidation:** `support-28` makes MailFake a faithful one-shot delivery boundary and refreshes the Mail facade from its concrete forwarded surface. `support-29` restores current single-label URL support across Str, Stringable, Mail, and Validation. `support-30` and `support-31` correct fake assertion types and regenerate the affected Notification facade metadata without adding runtime state. Current upstream MailFake and NotificationFake coverage passes; the separately missing EventFake test port remains recorded in `docs/todo.md`. - **Later Validation revalidation:** `validation-18` regenerates the Validator facade from its actual Factory owner, removing the unrelated concrete Validator surface while retaining the nullable verifier and DNS-faking APIs. Facade-documenter coverage and the complete gate revalidate Support without changing its runtime code. - **Later Pagination revalidation:** `support-32` makes `Fluent` and `MessageBag` fail through `JsonException` in one encoding pass while preserving caller flags and delegated pretty output. `support-33` ports `Lottery::choose()`'s conditional result type. Focused runtime and max-level type coverage revalidates the completed package without new state or runtime abstraction. +- **Later Socialite revalidation:** `support-34` makes `Manager::setContainer()` replace its configuration repository together with its container. Socialite deletes its duplicate workaround and proves a cached manager reads the rebound application's provider configuration. The completed `support-02` enum-driver boundary remains valid across Socialite's manager, factory, fake, and facade. - **Validation and review:** Every changed Support, Auth, Foundation-testing, Queue, Testbench, and cleanup regression is green. PHP CS Fixer, both PHPStan configurations, the complete parallel suite, both Testbench suites, `git diff --check`, broad stale-reference/API/parity scans, a fresh full-diff caller/callee and lifecycle review, and independent code review are complete. The final review added strict PendingBatchFake comparisons, confirmed the three local file-mode checks are preferable to a Laravel-divergent public Filesystem helper, and signed off with no remaining finding. - **Laravel-facing result:** Public API and configuration remain compatible. The work restores current Laravel fake, string, validated-input, Carbon metadata, and edge-case behavior; adds the matching facade metadata and documentation; and retains only the documented Hypervel omissions for deferred providers, `laravel/ui`, and Laravel's directly deprecated `loadFactoriesFrom()` surface. - **Assessment:** The final work fixes demonstrated utility, file-publication, fake, reflection, cleanup, and authentication-timing defects at their owning boundaries. Ordinary request paths are unchanged; the only new per-operation cost is one Timebox clone when an authentication or password-reset operation is actually timed. No lock, retry, coroutine context, registry, worker cache, general transaction/callable/serialization abstraction, or speculative compatibility machinery was added. The design is complete without overengineering. @@ -774,17 +776,19 @@ Append package entries in checklist order. Keep each entry compact but complete | `object-pool-01` | Defect | Minor | High | One throwing public-contract pool aborts an entire recycler tick and indefinitely starves every later registered pool of expiry, trim, and idle eviction | Isolate each complete per-pool maintenance transaction, report the registry identity with the original failure chained, skip later methods only for that failed pool, and retain the outer catch for factory-snapshot failures | | `object-pool-02` | Userland footgun | Minor | High | Factory flush and recycler configuration/lifecycle methods mutate singleton worker-wide state without consistently documenting their boot/test boundary at both contract and concrete surfaces | Add the standard warnings with concrete worker-wide consequences to `flush()`, `setInterval()`, `setTimer()`, `start()`, and `stop()`; leave runtime operations and pure readers unwarned | | `object-pool-03` | Improvement | Improvement | High | Object Pool, SimpleObjectPool, PoolManager, and the Sentry pool retain and forward an unread application Container while the split package directly requires Carbon after its only user was deleted | With owner approval, remove the false constructor/property/import chain and every construction-site argument, drop only the stale direct Carbon requirement, and correct only the obsolete constructor snippets in the completed lifecycle plan | +| `object-pool-04` | Container identity defect | Major | High | Resolving concrete `PoolManager` or `PoolRecycler` separately from their contracts creates multiple worker-lifetime registry or timer owners | Alias each concrete to its canonical contract so every resolution shares one owner while preserving later application bindings | - **Owner-approved improvement boundary:** The owner approved `object-pool-03` after reviewing its practical benefit, constructor churn, leave-as-is alternative, parity effect, hot-path effect, and overengineering assessment. Keep `hypervel/container` and `hypervel/contracts` because PoolErrorReporter, StartRecycler, and PoolManager's custom pool-factory resolution still legitimately use them. Remove no compatibility shim: these constructors are Hypervel-specific, Laravel parity is unaffected, and Hyperf API parity is not required. -- **Important rejected concerns:** Do not add manager locking, proxy pool caching or generation tracking, destructor/native-teardown changes, worker-exit flushing, protected-channel ownership assertions, generic discard-on-operation-exception behavior, discard-time activity stamping, speculative test barriers, concrete container aliases, per-method recovery within one failing pool, or a generic contextual reporting API. The current manager publishes only after synchronous construction; leases and proxies already own terminal cleanup; pool teardown is explicitly native-free where destructors can reach it; supported lifecycle closure is deterministic; and the recycler needs only one direct per-pool catch. +- **Important rejected concerns:** Do not add manager locking, proxy pool caching or generation tracking, destructor/native-teardown changes, worker-exit flushing, protected-channel ownership assertions, generic discard-on-operation-exception behavior, discard-time activity stamping, speculative test barriers, per-method recovery within one failing pool, or a generic contextual reporting API. The current manager publishes only after synchronous construction; leases and proxies already own terminal cleanup; pool teardown is explicitly native-free where destructors can reach it; supported lifecycle closure is deterministic; and the recycler needs only one direct per-pool catch. - **Cross-package implications:** Sentry construction sites and affected Object Pool consumers/tests must follow the constructor correction. No completed lower-level assumption changes and no later revalidation dependency is introduced. - **Performance and compatibility:** Normal pool get, borrow, release, discard, proxy, request, job, and transport paths gain no work. Recycler maintenance gains one direct try/catch per registered pool at the existing ten-second interval; the identity wrapper allocates only after an actual failure. Constructor cleanup marginally reduces pool creation work and retained references. No Laravel-facing API, configuration, documented behavior, or conventional extension pattern changes. - **Regression strategy:** Prove that a first pool failing in `sweepExpired()` is reported with its identity and exact original failure, does not continue to `trimIdle()`, and cannot prevent a second pool from completing all maintenance methods. Update constructor coverage and all affected callers, run each changed test file immediately, run focused Object Pool and affected consumer tests, validate the split manifest, run `composer fix`, then perform a fresh caller/callee, lifecycle, API, performance, stale-code, and overengineering review before independent code review. - **Implementation:** PoolRecycler now isolates each complete pool maintenance transaction, reports an identity-named wrapper with the original failure chained, and documents why a throwing public-contract pool cannot be allowed to starve unrelated pools; the existing timer-level catch remains for factory-snapshot failure. Factory and Recycler contracts now carry the same worker-lifecycle warnings as their concretes. ObjectPool, SimpleObjectPool, PoolManager, and Sentry's transport Pool no longer retain or forward an application Container; every caller and test uses only the real constructor dependencies. The stale direct Carbon requirement is removed, while the still-used Container and Contracts dependencies remain. The completed Object Pool lifecycle plan describes the corrected constructors without altering its legitimate container behavior. +- **Later Socialite revalidation:** `object-pool-04` aliases `PoolManager` to Factory and `PoolRecycler` to Recycler, giving each worker one registry and timer owner without an explicit singleton binding. Focused provider coverage proves concrete and contract resolutions observe the same pools and interval state while existing application bindings still win. - **Regression tests:** The new recycler regression proves the old first-pool `sweepExpired()` failure is reported with its registry identity and exact original throwable, skips that pool's `trimIdle()`, and cannot prevent a later pool from completing `isIdle()`, `sweepExpired()`, and `trimIdle()`. Updated Object Pool, Filesystem, Queue, Broadcasting, and Sentry coverage exercises every corrected constructor and container-resolved PoolManager binding without weakening existing assertions. - **Validation and review:** Every changed test file passed immediately. The Object Pool package passes with 168 tests and 411 assertions; Sentry with 241 tests and 673 assertions; focused Queue manager resolution with 10 tests and 86 assertions; and Broadcasting manager integration with 20 tests and 66 assertions. Final `composer fix` changed no formatted file, both PHPStan configurations are green, 23,196 component tests and 66,057 assertions pass with 1,600 expected skips, 346 Testbench contract tests and 1,029 assertions pass with 3 expected skips, and 4 dogfood tests and 7 assertions pass. The split manifest validates strictly, `git diff --check` and repository-wide stale-constructor/Carbon scans are clean, and fresh full-diff lifecycle, API, performance, and overengineering review found no omission. Independent code review requested one useful WHY comment, re-reviewed it after the focused 19-test/57-assertion recycler run, and signed off. The owner reviewed the final package summary and approved committing. - **Laravel-facing result:** No Laravel public API, configuration key or structure, documented behavior, or conventional extension pattern changes. Object Pool and Sentry's pool constructor are Hypervel-specific; Hyperf parity is not required. The owner-approved constructor cleanup intentionally removes false Hypervel-specific arguments without a compatibility shim. -- **Assessment:** All three accepted findings are closed. Unrelated pools remain maintainable after one supported custom pool fails, lifecycle controls state their true worker-wide boundary, and constructors advertise and retain only real dependencies. The result adds only one cold per-pool try/catch at the existing maintenance interval and failure-only diagnostics, while deleting false dependencies and retained references. No request-hot-path work, lock, context state, registry, retry, timeout, cache, worker-exit hook, reporting abstraction, compatibility layer, workaround, or speculative machinery remains. +- **Assessment:** All four accepted findings are closed. Unrelated pools remain maintainable after one supported custom pool fails, lifecycle controls state their true worker-wide boundary, and constructors advertise and retain only real dependencies. The result adds only one cold per-pool try/catch at the existing maintenance interval and failure-only diagnostics, while deleting false dependencies and retained references. No request-hot-path work, lock, context state, registry, retry, timeout, cache, worker-exit hook, reporting abstraction, compatibility layer, workaround, or speculative machinery remains. ### Expose process stopping through the contract @@ -1473,11 +1477,13 @@ Append package entries in checklist order. Keep each entry compact but complete | `cache-20` | Cross-package type correction | Minor | High | Cache's striped-lock constants are untyped while deterministic timeout tuning relies on late static binding | Type the constants and preserve late binding only for the tunable timeout and spin count | | `server-10` | Cross-package callback-boundary defect | Critical | High | A pipe-message or task-finish exception can escape Swoole's native callback and kill a healthy serving worker | Contain cancellation and report ordinary failures at the Server-owned native boundary | | `grpc-01` | Cross-package test harness defect | Minor | High | The fake HTTP/2 client closes streams when responses are queued and treats local request half-close as terminal | Change fake state when the response is observed and keep half-closed calls alive for trailers | +| `reverb-40` | Container identity defect | Major | High | Resolving `ArrayChannelManager` separately from `ChannelManager` splits one worker's channel repository between concrete and contract consumers | Alias the concrete to the contract under the existing custom-binding guard so one repository owner serves both surfaces | - **Approved owner gates and intentional differences:** The owner approved per-connection lifecycle serialization, distributed presence/metrics, exact distributed user counts, truthful unscaled termination and event-publication failures, local membership reservations, Redis Cluster rejection for pub/sub scaling, non-blocking subscriber startup with persistent recovery, the additive scoped-manager contract, orphan trait deletion, and the mechanical test typing pass. Hypervel retains the public Pusher protocol and Laravel Reverb APIs while keeping its Swoole transport, shared-memory multi-worker design, pooled phpredis transport, Redis multi-instance scaling, Telescope integration, and existing webhook/payload enhancements. - **Important rejected concerns:** No reverse membership index, channel-wide lock, generic callback-ordering or distributed-query framework, arbitrary presence table, worker heartbeat, lease, fencing, reconciliation job, startup barrier, second subscriber supervisor, configurable retry policy, webhook recovery layer, client-auth registry, channel quota, sender-result API, shutdown-timeout override, or Redis Cluster accommodation was added. Abrupt worker death remains a documented rare limitation; a full server restart recreates unscaled state, while scaled Redis state requires operational cleanup. Redis subscriber startup may briefly omit a joining worker from distributed metrics rather than making Redis availability a worker-liveness dependency. - **Implementation:** Every fd now owns an exact lifecycle entry and same-socket token. Membership is idempotent, operation-local, transactionally ordered, and protected from concurrent local removal. Shared-state identity is canonical and Swoole-safe; presence transitions are atomic. Metrics carry only JSON-native slices, merge complete worker/node state, deduplicate users, and clean exact temporary ownership. Unscaled user termination and event publication reach every worker; scaled publication retains Redis semantics. Rejected opens, pruning, drain, rate-limit cleanup, subscriber recovery, webhook batching, Pusher validation, native callback containment, and isolated HTTP exception handling have truthful terminal owners. Current Reverb parity, configuration, contracts, metadata, docs, and dead-source removal are complete. - **Cross-package implications and revalidation:** `config-02`, `support-02`, `http-server-06`, `reverb-03`, `reverb-05`, `reverb-06`, `redis-10`, and `redis-11` were revalidated against Reverb's final behavior; this work completed the previously unshipped `reverb-04` timer-test correction. Foundation owns `reverb-24`; Cache owns the completed `cache-11` timer lifecycle and `cache-20` lock typing; Server owns `server-10`. The `grpc-01` harness correction is complete here and was revalidated by the completed gRPC audit. The post-merge review completed `websocket-server-13` at WebSocket Server's base handshake boundary, `testbench-02` at Testbench Commander's exception boundary, and `support-27` at SafeCaller's default-preserving reporting boundary; Testbench retains later full-audit revalidation. Reverb remains unaffected by the latter two base-boundary changes because it uses its isolated HTTP server and Foundation's WebSocket override. The confirmed Swoole partial-serialization-buffer defect is recorded for an upstream PR, but Reverb's integer connection metric is independently the correct long-term representation and no package workaround exists. +- **Later Socialite revalidation:** `reverb-40` aliases `ArrayChannelManager` to `ChannelManager` only when no application binding already exists. Concrete and contract consumers now share one worker-local repository, while pre-registration and post-registration application overrides retain precedence. - **Regression tests:** Deterministic unit, coroutine, integration, and native-subprocess coverage proves same-fd ordering with cross-fd concurrency; exact open/close/drain cleanup; membership interleavings; protected-channel verification; Swoole and Redis identity/atomicity; distributed metrics, presence, counts, and termination; native callback containment; persistent subscriber recovery; cache timer recycle; deterministic deferred webhooks; broadcast fan-out; durable webhook batching; Pusher error classification; isolated HTTP handler and emission failure chains; WebSocket Server and Testbench secondary-failure chains; SafeCaller reporter failure with exact default and both diagnostics; configuration, contracts, metadata, and dead-source removal; and corrected gRPC fake stream state. Real topologies cover standalone, unscaled multi-worker, scaled Redis, cross-server, scaled multi-worker, and Redis-backed state. - **Performance and complexity:** Single-worker metrics and presence remain local. The connection gate adds one local synchronization pair per callback without cross-socket contention; membership adds two integer mutations and local checks without a yield or allocation. Ordinary shared-state channels keep one operation, Redis presence drops from two round trips to one, direct socket lookup removes map allocations, and connection metrics transport one integer per worker/node. Only distributed presence and explicitly requested distributed user counts carry topology-dependent work. Subscriber waiting exists only while disconnected; cache timer cleanup is worker-exit-only; exception-preservation frames exist only after the primary operation has failed; SafeCaller fallback containment runs only after its reporter also fails. No request hot path gains a retry, registry, heartbeat, reconciliation system, unbounded retained state, or additional ordinary network round trip. - **Laravel-facing result:** Public Pusher messages, HTTP shapes, Laravel Reverb signatures, named arguments, configuration, and extension points remain compatible. `ScopedChannelManager` is additive. Intentional observable differences are limited to complete distributed presence, truthful unscaled fan-out failures, and rejecting an unsupported Redis Cluster scaling topology. `ArrayChannelManager::flush()` deliberately clears the owned repository directly because the upstream per-application loop does not satisfy Hypervel's repository contract. @@ -1933,3 +1939,30 @@ Append package entries in checklist order. Keep each entry compact but complete - **Validation and review:** Every changed test and affected Translation, Integration Translation, Foundation, Auth, and Validation suite is green, including random-order integration coverage. The static contract probe, facade generation/drift test, dogfood dependency resolution, formatting, both PHPStan configurations, `git diff --check`, and the authoritative `composer fix` gate passed. Review independently reproduced the JSON sentinel and scalar rejection through a real filesystem, verified every conditional-helper inference, and signed off with no remaining finding. - **Owner handoff observations:** `AGENTS.md` still points to `docs/ai/differences-vs-laravel.md` as current although that guide is marked for deletion. `tests/Support/SupportUriTest.php` still contains old versioned Hypervel documentation URLs as inert parser fixtures. Neither affects Translation behavior, and neither was changed in this work unit. - **Assessment:** Translation is contract-complete, coroutine-safe, worker-lifecycle-aware, failure-truthful, and current at the audited Laravel surface. Every accepted finding is fixed at its lowest owner without a workaround, speculative abstraction, stale compatibility path, meaningful performance regression, or unresolved defect. + +### Complete Socialite correctness, first-party extensibility, and lifecycle + +- **Status and inspected surface:** Complete; implementation, the authoritative gate, fresh source/test/documentation review, and independent code review are signed off. The audit covered every Socialite source and test file, current Laravel Socialite, the SocialiteProviders Manager and Providers ecosystems, OAuth 2.0 and OpenID Connect protocol boundaries, dynamic tenant configuration, coroutine and worker ownership, container aliases, Support manager rebinding, Object Pool and Reverb state owners, split metadata, facade generation, and public documentation. The detailed design is recorded in [`2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md`](2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md). + +| Findings | Final decision | +|---|---| +| `socialite-01`, `socialite-04`, `socialite-06` | Move Bitbucket, GitLab, and generic OIDC user credentials from URLs to Bearer headers; use GitLab's current `/api/v4/user` endpoint while retaining Facebook's documented query-token request. | +| `socialite-02`, `socialite-03` | Port current LinkedIn optional-image handling and OAuth2 `User::fake()` behavior with strict Hypervel types and coverage. | +| `socialite-05`, `socialite-21`, `socialite-25`, `socialite-26` | Validate exact Google issuers and complete scalar/list audiences, consume nonce once only when enabled, preserve discovery causes, and use runtime exceptions for operational metadata failures. | +| `socialite-07`, `socialite-09` | Delete dead OIDC payload and OAuth1-only formatting paths; record the intentional OAuth1/legacy Twitter omission and `x` replacement at natural sync and documentation surfaces. | +| `socialite-10`, `socialite-11`, `socialite-12`, `socialite-22`, `socialite-23` | Separate boot configuration from coroutine-local request overrides, protect provider context internals, preserve null and `"0"` config keys, use non-recyclable provider namespaces, and keep request ownership in coroutine context. | +| `socialite-13`, `socialite-14`, `socialite-17`, `socialite-18` | Make provider/response types and facade metadata truthful, redact every secret-bearing frame, and give Factory and concrete manager resolutions one worker-lifetime owner while preserving Factory-only fakes. | +| `socialite-19` | Use one bounded exact-URL JWKS concern across generic OIDC, Google, and Facebook, with cache-directive expiry, local-before-publication parsing, and one throttled rotation retry; remove manual RSA construction and phpseclib. | +| `socialite-20`, `socialite-24`, `socialite-27` | Centralize token response parsers and whole-response mapping, preserve refresh tokens, publish the complete response directly on the returned user, and cache a user only after every parser and setter succeeds. | +| `socialite-15`, `socialite-16` | Document stateless session limits, dynamic redirect/callback configuration, custom OAuth2/OIDC providers, parser hooks, request access, complete token responses, registration, and testing in Laravel-docs prose. | +| `socialite-08` | Keep the direct Collections dependency because Socialite uses its `Arr` and `value()` symbols. | + +- **Architecture and worker ownership:** One cached provider remains shared per driver and worker. Immutable baseline configuration and bounded one-entry discovery/JWKS caches remain worker-local; request, credentials, scopes, state, HTTP client, and user memoization remain coroutine-local. A monotonic per-process provider namespace prevents recycled object handles from exposing another provider's context and is intentionally never reset. Token responses move only through local parameters and the returned User; no provider property or context slot retains them. +- **Cross-package completion:** `support-34` refreshes both container and configuration references in the shared Support manager owner, allowing Socialite to delete its duplicate rebinding workaround. `object-pool-04` and `reverb-40` give Pool Manager/Recycler and Reverb's array channel manager one canonical container identity each while preserving application overrides. The completed `support-02` enum identifier contract remains correct across Socialite's manager, Factory, fake, and facade. +- **Correctness, security, and parity:** OAuth2 Laravel-facing APIs, named arguments, protected extension points, and provider ergonomics remain compatible. Hypervel retains its approved OAuth1/legacy Twitter omission and `x` driver, while its additive custom-provider, dynamic-config, parser, complete-response, and trusted-audience surfaces make first-party extension packages unnecessary for ordinary OAuth2/OIDC providers. Provider credentials no longer appear in corrected URLs, OIDC validation is exact without imposing a universal `azp`, discovery and key rotation preserve failure causes, and caught exchange failures cannot return a partial cached user. +- **Important rejected concerns:** No provider or event registry, mutable config DTO, provider allowlist, OAuth1 compatibility layer, shared mutable client, per-coroutine provider clone, UUID or WeakMap namespace, state/nonce rollback system, overlapping-flow registry, encrypted stateless transport, lock, singleflight, tenant map, LRU, timer, background refresh, framework cache adapter, Firebase `CachedKeySet`, configurable token transport, response pipeline, exception hierarchy, creator-result guard, or dual fake swap was added. Stateless container bindings were not rewritten for symmetry, and harmless duplicate cold JWKS fetches remain accepted. +- **Upstream handoff:** `socialite-04` is also present in current Laravel Socialite: GitLab user lookup sends the access token in the query. The minimal upstream correction is the provider request change to `/api/v4/user` with Bearer authorization plus the focused request-shape regression; it requires no Hypervel-specific lifecycle adaptation. +- **Regression coverage:** Tests prove exact Bearer request shapes, dynamic config isolation and rebinding, request ownership, non-recyclable provider context identity, Factory/concrete identity and fake behavior, parser matrices and zero-padded protocol integers, refresh retention, transactional user publication, complete response mapping, sensitive parameters, OIDC discovery/audience/issuer/nonce behavior, bounded JWKS expiry/rotation/failure paths across all three providers, current user fakes and LinkedIn mapping, facade and package metadata, and the three cross-package container identities. +- **Performance and complexity:** Ordinary non-Socialite requests are unchanged. Provider construction adds one non-yielding integer increment, request paths add bounded local array/string checks beside existing network work, and JWKS reuse removes repeated network requests while retaining one key set and one refresh timestamp per provider. No request path gains a lock, timer, background job, registry, unbounded map, clone, container lookup, serialization layer, or additional ordinary network round trip. +- **Validation and review:** Changed tests passed during implementation; focused Socialite, Support, Object Pool, and Reverb coverage, root and split Composer validation, facade and documentation checks, stale-symbol scans, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, and `git diff --check` passed. Review independently reproduced the provider-namespace collision, stale config rebinding, nonce-disabled failure, partial-user memoization, and response-state risks, then signed off after every source and plan correction landed. +- **Assessment:** Socialite is coroutine-safe, worker-lifecycle-aware, protocol-correct, current at the supported Laravel surface, and first-party extensible without ecosystem-manager machinery. Every accepted finding is fixed at its lowest owner; no stale response state, compatibility workaround, speculative abstraction, unresolved accepted defect, meaningful performance regression, or deferred TODO remains. From d5541376434c50e7d41788632b5d19ebfa4bdc1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:38:26 +0000 Subject: [PATCH 10/14] docs: add the Socialite correctness plan Document the final Socialite architecture, accepted findings, rejected complexity, implementation boundaries, regression strategy, and performance and compatibility gates. The plan captures the current Laravel and SocialiteProviders research, OAuth and OpenID Connect protocol decisions, coroutine and worker ownership model, shared JWKS design, cross-package corrections, and completion review used for this work. --- ...first-party-extensibility-and-lifecycle.md | 472 ++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md diff --git a/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md b/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md new file mode 100644 index 000000000..2e27ed9b1 --- /dev/null +++ b/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md @@ -0,0 +1,472 @@ +# Socialite Correctness, First-Party Extensibility, and Lifecycle + +## Scope and outcome + +Complete the Socialite audit against the current Hypervel package, Laravel Socialite, the SocialiteProviders Manager/Providers ecosystem, OAuth 2.0 and OpenID Connect requirements, and the current container/coroutine runtime. Preserve the existing high-performance shape: one cached provider per driver and worker, with request-mutated state isolated in `CoroutineContext`. + +The final package must provide secure request transport, exact request/tenant isolation, bounded worker-local JWKS reuse, truthful OAuth response models and types, Laravel-style first-party provider extension APIs, and complete user documentation. Fix the related Support, Object Pool, and Reverb container-owner defects at their actual owners. Do not add compatibility wrappers for superseded Hypervel APIs. + +References checked for this design: + +- current Hypervel Socialite source, tests, split metadata, facade, README, and Boost guide; +- Laravel Socialite `27702f45183f1ee4b00cc3a7237b626678b3b4ae`, including Bitbucket, LinkedIn, user factories, tests, and docs; +- SocialiteProviders Manager `35372dc62787e61e91cfec73f45fd5d5ae0f8891` and Providers `257a17f2033fd7bbbc2620d51952ad2a339fe8d4`; +- RFC 6749 token refresh/expiry behavior, OpenID Connect Core 3.1.3.7 audience rules, Google issuer/JWKS guidance, Meta Secure Requests guidance, and installed `firebase/php-jwt` 7.x; +- Hypervel Container alias replacement, Support Manager state, Object Pool manager/recycler state, and Reverb channel-manager state. + +Evidence baseline: Hypervel `128c71b7384be0cd94eaae7b38594141a9cd0145`. Focused probes reproduced a released transient provider object's recycled ID carrying tenant A's credentials, Guzzle client, and mutable context into tenant B; the current Factory binding resolving a different `SocialiteManager` concrete; and level-5 PHPStan accepting the nullable OIDC result passed to an array-only boundary. + +## What this audit is not + +The following wording is retained verbatim from the core audit plan. Its principle numbering is also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this plan” refers to that plan's **Established remediation vocabulary** section. + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Findings and final decisions + +| ID | Category / severity | Final decision | +|---|---|---| +| `socialite-01` | Security defect / Major | Send both Bitbucket user and email tokens in `Authorization: Bearer`; never place them in URLs. | +| `socialite-02` | Upstream defect / Minor | Port current LinkedIn `StillImage` handling: hoist the optional node to `[]` and terminate width reads with `?? null`. | +| `socialite-03` | Current parity improvement | Port OAuth2 `Two\User::fake()` and current focused tests/docs; OAuth1 remains unsupported. | +| `socialite-04` | Provider/security defect / Major | Use GitLab `/api/v4/user` and Bearer auth. This also corrects current Laravel Socialite behavior. | +| `socialite-05` | JWT validation defect / Major | Accept exactly Google's bare and HTTPS issuer forms; use the package's named issuer/audience exceptions without flattening their cause. | +| `socialite-06` | Security defect / Major | Send generic OIDC UserInfo tokens through Bearer auth while retaining the JSON accept header. | +| `socialite-07` | Dead code / Minor | Delete unused `appendOIDCPayload()`. | +| `socialite-08` | Rejected metadata concern | Keep `hypervel/collections`; Socialite directly uses its `Arr` and `value()` symbols. | +| `socialite-09` | Divergence/docs defect / Minor | Record OAuth1 and legacy Twitter omission, the `x` replacement, and the final first-party extension surface in README/source/test/docs; remove obsolete `formatConfig()`. | +| `socialite-10` | Worker-lifecycle footgun / Major | Mark `withConfig()` boot-only and document `setConfig()` as the request/coroutine override that must be applied independently on redirect and callback requests. | +| `socialite-11` | API cleanup / Minor | Move `HasProviderContext` to `Concerns`, make raw context methods protected, and expose no generic application state API. | +| `socialite-12` | Config-key defect / Minor | Delegate all keys, including null and `"0"`, directly to `Arr::get()`. | +| `socialite-13` | Type/API defect / Minor | Return `Contracts\Provider` from Factory/manager/fake, `Two\AbstractProvider` from the generic builder, and arrays from token/user-response boundaries. Remove false nullable results; rely on native return types rather than a duplicate `instanceof` guard. | +| `socialite-14` | Facade metadata defect / Major | Generate the facade only from `SocialiteManager`; do not advertise direct provider calls that cannot select a default driver. | +| `socialite-15` | Documentation defect / Minor | Explain that `stateless()` disables OAuth state checks but X PKCE and generic OIDC nonce validation still require session continuity. | +| `socialite-16` | Extension documentation gap / Minor | Document `OpenIdProvider`, custom OAuth2 providers, parser hooks, request access, full token responses, and provider registration in Laravel-docs prose. | +| `socialite-17` | Secret-handling defect / Major | Apply `#[SensitiveParameter]` to every active client-secret, token, code, ID-token, secret config, and token-response frame. Keep user profiles, client IDs, state, nonce, and scopes diagnostic, and derive reflection coverage from the provider surface rather than maintaining a duplicate method inventory. | +| `socialite-18` | Container identity defect / Major | Make `SocialiteManager` the canonical auto-singleton and alias Factory to it. Preserve Factory-only facade fake swaps and delete duplicate manager overrides. | +| `socialite-19` | Tenant-correctness/performance/availability defect / Major | Key generic OIDC discovery by its exact URL and use one bounded exact-URL parsed-JWKS concern for generic OIDC, Google, and Facebook, including cache directives and one throttled rotation retry. Remove manual Facebook RSA construction and `phpseclib`. | +| `socialite-20` | OAuth response defect / Major | Preserve the submitted refresh token when rotation omits one; add four protected response parsers and a whole-response user-mapping seam, use nullable exact expiry parsing, and remove redundant Google/Twitch/OIDC orchestration. | +| `socialite-21` | OIDC validation defect / Major | Accept scalar/list audiences, require this client ID, reject additional audiences unless listed in `trusted_audiences`, and do not universally require `azp`. Apply to generic OIDC, Google, and Facebook. | +| `socialite-22` | Coroutine context identity defect / Major | Replace recyclable object-ID namespaces with a lazy monotonic process-lifetime sequence that is intentionally never reset. | +| `socialite-23` | Request ownership defect / Major | Remove the retained Request property, seed context in construction, refresh it on cached-driver resolution, and fail clearly when a provider is used without request context. | +| `socialite-24` | Extension/coroutine-safety defect / Major | Expose the complete access-token response on `Two\User` by passing the current response directly through user construction; retain no response state on the worker provider. | +| `socialite-25` | OIDC lifecycle/diagnostic defect / Minor | Consume nonce once, preserve discovery failures as previous exceptions, and classify discovered metadata failures as runtime errors rather than bad caller arguments. | +| `socialite-26` | OIDC validation defect / Major | Require and validate a nonce only for providers whose flow enables nonce protection. | +| `socialite-27` | Coroutine memoization defect / Major | Publish the authenticated user only after response parsing and every setter succeed, so a caught exchange failure cannot leave a partial cached user. | +| `support-34` | Cross-package manager defect / Major | `Support\Manager::setContainer()` must refresh both container and configuration references; Socialite deletes its workaround. | +| `object-pool-04` | Completed-package state-owner defect / Major | Alias concrete `PoolManager`/`PoolRecycler` to Factory/Recycler so each worker has one pool registry and one timer owner. | +| `reverb-40` | Completed-package state-owner defect / Major | Alias `ArrayChannelManager` to `ChannelManager` under the existing custom-binding guard so concrete and contract users share one channel repository. | + +Fortify response bindings and Database's entity resolver were inspected and remain ordinary bindings: those concretes are stateless, so distinct instances do not split ownership. Facebook Graph profile lookup retains query parameters because Meta documents `access_token` and `appsecret_proof` there and does not document an equivalent Bearer request for that endpoint. + +## Implementation + +### 1. Make provider context identity and request ownership exact + +Move the concern to `src/socialite/src/Concerns/HasProviderContext.php`. Use one non-yielding increment per provider instance rather than a recyclable object handle: + +```php +protected static int $nextContextNamespace = 0; + +protected function getContextKey(string $key): string +{ + $namespace = $this->contextNamespace + ??= '__socialite.providers.' . ++self::$nextContextNamespace; + + return $namespace . '.' . $key; +} +``` + +The sequence has process lifetime and no `flushState()`: resetting it while non-coroutine context entries remain would recreate the collision. Keep a short declaration comment that `Socialite\AbstractProvider` must remain the concern's only root user; an unrelated second root user would own a separate trait static counter while sharing the fixed key prefix. No lock is needed because increment and assignment do not yield. Add protected `getContext()`, `setContext()`, `getOrSetContext()`, and `forgetContext()` only. + +The provider constructor seeds the current request into context and retains no Request property: + +```php +public function __construct(Request $request, protected array $guzzle = []) +{ + $this->setRequest($request); +} + +protected function getRequest(): Request +{ + $request = $this->getContext('request'); + + if (! $request instanceof Request) { + throw new LogicException( + 'No request is available for this provider. Resolve it through Socialite::driver() or call setRequest().' + ); + } + + return $request; +} +``` + +`SocialiteManager::driver()` continues refreshing cached `AbstractProvider` instances from the current request binding. Direct construction works in the constructor's coroutine; reuse in another coroutine requires `setRequest()` and otherwise fails instead of reading a stale request. + +### 2. Give each stateful service one container identity + +Use Hypervel's established concrete-auto-singleton alias shape: + +```php +// Socialite +$this->app->alias(SocialiteManager::class, Factory::class); + +// Object Pool +$this->app->alias(PoolManager::class, Factory::class); +$this->app->alias(PoolRecycler::class, Recycler::class); + +// Reverb, inside the existing custom-binding guard +$this->app->alias(ArrayChannelManager::class, ChannelManager::class); +``` + +Do not explicitly singleton-bind these concrete classes. `Container::bind()` and `instance()` drop a stale alias, so later application bindings and facade swaps still win. `Socialite::fake()` intentionally swaps only Factory; direct concrete resolution remains the real manager. + +Fix the shared manager mutation and delete Socialite's duplicate methods: + +```php +public function setContainer(Container $container): static +{ + $this->container = $container; + $this->config = $container->make('config'); + + return $this; +} +``` + +Update the base method's tests-only warning so it states that both the container and configuration references are swapped. + +### 3. Make provider types and OAuth response parsing truthful + +Use native domain types throughout: + +```php +interface Factory +{ + public function driver(UnitEnum|string|null $driver = null): Provider; +} + +/** + * @template TProvider of Two\AbstractProvider + * @param class-string $provider + * @return TProvider + */ +public function buildOAuth2Provider(string $provider, #[SensitiveParameter] ?array $config): Two\AbstractProvider; +``` + +`with()` and fake/manager `driver()` return `Provider`. Token exchange and user lookup boundaries return `array`; generic OIDC methods are non-nullable. Regenerate the facade from the manager only. + +Add four protected parsers used by both login and refresh: + +```php +protected function parseAccessToken(#[SensitiveParameter] array $response): string +{ + return Arr::get($response, 'access_token'); +} + +protected function parseRefreshToken(#[SensitiveParameter] array $response): ?string +{ + return Arr::get($response, 'refresh_token'); +} + +protected function parseExpiresIn(#[SensitiveParameter] array $response): ?int +{ + $expiresIn = Arr::get($response, 'expires_in'); + + if (is_int($expiresIn)) { + return $expiresIn >= 0 ? $expiresIn : null; + } + + if (! is_string($expiresIn) || ! ctype_digit($expiresIn)) { + return null; + } + + $normalized = ltrim($expiresIn, '0'); + $parsed = filter_var($normalized === '' ? '0' : $normalized, FILTER_VALIDATE_INT); + + return $parsed === false ? null : $parsed; +} + +protected function parseApprovedScopes(#[SensitiveParameter] array $response): array +{ + $scopes = Arr::get($response, 'scope'); + + return is_array($scopes) + ? $scopes + : (is_string($scopes) && $scopes !== '' ? explode($this->scopeSeparator, $scopes) : []); +} +``` + +This accepts non-negative integer and bounded digit-string expiry values, including zero-padded strings allowed by RFC 6749's `1*DIGIT` grammar, preserves zero, and maps absent/malformed advisory expiry to null. Normalize leading zeroes before `FILTER_VALIDATE_INT`; keep this local to the parser rather than coupling it to the independent JWKS cache parser. `parseAccessToken(): string` intentionally fails at the parser with a native `TypeError` when a successful token response has no string access token, instead of widening the hook and passing an invalid value deeper into `getUserByToken(string $token)`. `Token::$expiresIn` becomes `?int`; its refresh token remains `string`, using the submitted value when the response omits rotation. Delete `GoogleProvider::refreshToken()` and Twitch's `userInstance()` / `refreshToken()` overrides because the base parsers now preserve their behavior. Keep Slack's response-shape handling, X's transport overrides, Facebook's expiry-key translation, and every other genuinely provider-specific seam. + +Add a whole-response user lookup seam to the base provider: + +```php +protected function getUserByTokenResponse(#[SensitiveParameter] array $response): array +{ + return $this->getUserByToken($this->parseAccessToken($response)); +} +``` + +Delete `OpenIdProvider::user()` and retain only its non-nullable one-line override of this seam, returning `getUserByOIDCToken($response['id_token'])`. Its direct required-key access is deliberate: a successful OIDC token response without an ID token raises the missing-key diagnostic and native string-boundary `TypeError` instead of widening the hook or passing an invalid value into JWT decoding. This preserves OIDC behavior while making the shared orchestration and cleanup authoritative for every provider, including custom providers that map users from the complete token response. + +Add `Two\User::$accessTokenResponseBody = []` and its fluent setter. Pass the current response directly through the shared orchestration and retain the cached-user and invalid-state guards in their current order: + +```php +public function user(): User +{ + if ($user = $this->getUser()) { + return $user; + } + + if ($this->hasInvalidState()) { + throw new InvalidStateException; + } + + $response = $this->getAccessTokenResponse($this->getCode()); + + return $this->userInstance($response, $this->getUserByTokenResponse($response)); +} +``` + +`userInstance()` copies its response argument to the returned user, fully decorates the local object, and calls `setUser()` only after every parser and setter succeeds. `userFromToken()` leaves the returned user's response body at its empty default because no token exchange occurred. The provider retains no response property, context slot, accessor, cleanup branch, or response pipeline. + +```php +protected function userInstance(#[SensitiveParameter] array $response, array $user): User +{ + $instance = $this->mapUserToObject($user); + + $instance->setToken($this->parseAccessToken($response)) + ->setRefreshToken($this->parseRefreshToken($response)) + ->setExpiresIn($this->parseExpiresIn($response)) + ->setApprovedScopes($this->parseApprovedScopes($response)) + ->setAccessTokenResponseBody($response); + + $this->setUser($instance); + + return $instance; +} +``` + +Port `Two\User::fake(array $attributes = [])` from current upstream, adapted to strict Hypervel types. Include `accessTokenResponseBody` with a default empty array, pass an override through its setter, and cover both behaviors. + +### 4. Secure provider transport and OIDC validation + +Apply the exact request corrections in Bitbucket, GitLab, and generic OIDC. Port current LinkedIn optional-image mapping. Preserve Facebook's documented query-token profile request. + +Centralize audience validation on `Two\AbstractProvider`: + +```php +protected function validateAudience(mixed $audience): void +{ + $audiences = is_array($audience) ? $audience : [$audience]; + + $trustedAudiences = Arr::wrap($this->getConfig('trusted_audiences', [])); + $trusted = [$this->getClientId(), ...$trustedAudiences]; + + if (! in_array($this->getClientId(), $audiences, true)) { + throw new InvalidAudienceException; + } + + foreach ($audiences as $candidate) { + if (! is_string($candidate) || ! in_array($candidate, $trusted, true)) { + throw new InvalidAudienceException; + } + } +} +``` + +Use it in generic OIDC, Google, and Facebook. A single trusted audience may be configured as a string, matching the package's existing string-or-array scope convention. Reject missing/invalid audiences and untrusted extras; ignore `azp` rather than imposing an extension-specific rule. Google accepts only `accounts.google.com` and `https://accounts.google.com`; delete its catch-all verification wrapper so named JWT/issuer/audience failures retain their type and cause. Facebook and generic OIDC retain their exact issuer rules. + +Consume OIDC nonce with `session()->pull('nonce')`. Wrap discovery failures with `previous: $exception`. `ConfigurationFetchingException` and `InvalidUserInfoUrlException` extend `RuntimeException`; issuer/audience/nonce/state validation remains in the invalid-argument family. + +### 5. Share one bounded JWKS implementation + +First make generic discovery tenant-correct with one atomically assigned URL/config entry: + +```php +/** @var null|array{url: string, config: array} */ +protected ?array $openidConfig = null; + +protected function getOpenIdConfig(bool $refresh = false): array +{ + $url = $this->getOpenIdConfigUrl(); + + if (! $refresh && ($this->openidConfig['url'] ?? null) === $url) { + return $this->openidConfig['config']; + } + + try { + $response = $this->getHttpClient()->get($url); + $config = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR); + + if (! is_array($config) || array_is_list($config)) { + throw new UnexpectedValueException('The OIDC configuration response must be a JSON object with named fields.'); + } + } catch (Throwable $exception) { + throw new ConfigurationFetchingException( + 'Unable to get the OIDC configuration from ' . $url . ': ' . $exception->getMessage(), + previous: $exception, + ); + } + + $this->openidConfig = ['url' => $url, 'config' => $config]; + + return $this->openidConfig['config']; +} +``` + +The implementation must derive the current URL before reuse, fetch/decode into a local, preserve the previous throwable, and publish URL plus config in one assignment. Reject the empty decoded object deliberately: `json_decode('{}', true)` produces `[]`, which is list-shaped and contains none of the required named metadata. A concurrent tenant switch can cause a later refetch but can never return configuration for the wrong URL. Forced JWKS refresh must still call `getJwksUri(true)` and therefore refresh discovery once the cooldown permits network work. + +Add `src/socialite/src/Two/Concerns/InteractsWithJwks.php` and use it from generic OIDC, Google, and Facebook. The concern owns: + +```php +/** @var null|array{url: string, keys: array, expiresAt: ?int} */ +protected ?array $jwks = null; + +/** @var null|array{url: string, attemptedAt: int} */ +protected ?array $jwksRefreshAttempt = null; + +protected int $jwksRefreshCooldownSeconds = 10; +``` + +Keep the concern's key-fetch/cache helpers private. The only per-provider override is `protected function getJwksUri(bool $refresh = false): string`: generic OIDC refreshes discovery when requested, while Google and Facebook return fixed URLs. The concern also owns one shared protected decode boundary: + +```php +protected function decodeUsingJwks(#[SensitiveParameter] string $token): array +``` + +It fetches the parsed keys, calls `JWT::decode()`, and performs the single throttled retry after `SignatureInvalidException` or when the firebase/php-jwt message contains `"kid" invalid`. Do not retry the sibling `"kid" empty` failure: refreshing keys cannot repair a token with no key identifier. Generic OIDC, Google, and Facebook call the shared boundary once and retain only their own payload validation/mapping; Facebook keeps its no-`kid` fallback to ordinary access-token lookup. Replace `getGoogleJwks()`, `getPublicKeyOfOIDCToken()`, the old generic cache, and all provider-local retry catches without wrappers. + +The fetch algorithm must: + +1. derive the lookup URL with `getJwksUri(false)` before consulting the one-entry cache; +2. on an ordinary read, return matching keys while `expiresAt === null` or the current `time()` is before expiry; +3. on a forced read, return matching cached keys when the refresh-attempt entry matches the lookup URL and remains inside the cooldown, without calling `getJwksUri(true)` or performing any HTTP request; +4. otherwise stamp the lookup URL with one captured `time()` before refresh I/O, call `getJwksUri(true)`, and replace the stamp's URL with the refreshed URL before the JWKS request if discovery changed it; +5. fetch, throwing-decode, and `JWK::parseKeySet()` into locals, then atomically assign the complete refreshed URL/keys/expiry entry; +6. let `decodeUsingJwks()` retry JWT decoding once through the forced path. + +Replacing the stamp URL aligns a successful newly discovered key set with later cooldown checks. Stamping before I/O also throttles a same-URL discovery or JWKS failure when matching cached keys remain. A cold failure, or a changed-URL failure with no matching keys, deliberately retries on the next login because the cooldown has no correct keys it can return; do not add a second failure-only timestamp or suppression path. + +Parse every `Cache-Control` header value and every comma-separated directive case-insensitively. `no-cache` or `no-store` anywhere wins and makes the entry immediately stale. Accept `max-age=N` only when `N` is a non-negative decimal integer no greater than `PHP_INT_MAX - $now`, including zero-padded values allowed by HTTP's `1*DIGIT` grammar; when repeated valid values exist, use the smallest. Normalize leading zeroes locally before `FILTER_VALIDATE_INT`. Ignore malformed, negative, and out-of-range max-age values. If no usable directive remains, retain the entry without expiry, preserving generic OIDC's current cache-until-failure behavior. Deliberately ignore `Expires`. + +Use `time()` for both expiry and cooldown. A wall-clock jump may shorten or extend reuse, but signature/kid failure still forces refresh; mixing `hrtime()` with protocol timestamps would add complexity without improving the contract. Headerless retention is safe only because forced refresh lands in the same change. + +Facebook now decodes through the shared boundary, so unknown kids produce the library's authentication failure instead of a null dereference. Remove `phpseclib/phpseclib` from the Socialite split package and, because no other package uses it, remove the root direct dependency and update the lock through Composer. + +### 6. Mark the complete secret-bearing call chain + +Import `SensitiveParameter` and mark parameters on: + +- manager provider construction/redirect formatting where the config contains `client_secret`; +- base and OAuth2 constructors/config setters for secret config and `clientSecret`; +- every public/protected built-in-provider frame accepting access tokens, refresh tokens, ID tokens, authorization codes, or token-response arrays; +- the four response parsers and `userInstance()` response input; +- `Two\Token` token/refresh-token constructor inputs; +- `Two\User` token, refresh-token, and complete-response setters. + +Add one reflection regression that derives the built-in provider classes from `SocialiteManager`'s `create*Driver()` return types, includes the root/OAuth2/OIDC bases plus `Token` and `User`, and checks semantic parameter names and token-response method inputs rather than enumerating class/method/parameter tuples. It must automatically cover new first-party providers and their overrides. Treat `clientSecret`, access/refresh/ID token, authorization-code, secret-bearing provider config, and the response inputs to the four parsers, `getUserByTokenResponse()`, and `userInstance()` as sensitive. Do not mark user profile arrays, client IDs, state, nonce, scopes, or non-secret identifiers. Redacting a complete token response reduces stack-argument diagnostics, but Guzzle exceptions retain the HTTP response at the correct transport boundary; credential secrecy takes precedence. + +### 7. Finish the first-party extension and documentation surface + +Delete OAuth1-only `SocialiteManager::formatConfig()`, its facade line, and the guide paragraph. Delete dead `appendOIDCPayload()` and duplicate manager overrides. Regenerate the facade docblock from `SocialiteManager` and run the facade-documenter lint. + +Update `src/socialite/README.md` using the package README convention: + +1. package heading; +2. `Documentation: https://hypervel.org/docs/socialite`; +3. concise `Differences From Laravel` covering no OAuth1/legacy Twitter, the `x` driver, `buildOAuth2Provider`, dynamic config/request access, removed OAuth1-only `formatConfig`, trusted audiences, and session-dependent stateless limitations; +4. `Ported from: https://github.com/laravel/socialite`. + +Update `src/boost/docs/socialite.md` in Laravel-docs prose: + +- use `User::fake()` in testing examples; +- register custom providers and call `withConfig()` during provider boot; +- explain that `setConfig()` is coroutine-local and must be reapplied independently on redirect and callback requests; +- show OAuth2 parser hooks, `getUserByTokenResponse()`, `accessTokenResponseBody`, and `getRequest()` for ports that currently override `user()` or read `$this->request`; +- document generic `OpenIdProvider`, `trusted_audiences`, Bearer UserInfo, nonce/session behavior, and JWKS rotation without exposing internal cache mechanics; +- clarify Factory-only fake replacement and the PKCE/OIDC limits of `stateless()`. + +Add concise source and matching test `REMOVED:` markers at the natural Laravel OAuth1/Twitter synchronization points. Do not document internal coroutine implementation as a Laravel difference. + +Apply native `: void` to the existing Socialite test methods and type nullable Client fixture properties while editing those files; do not create a separate test abstraction. + +### 8. Update durable audit records + +After implementation and review: + +- add one Socialite work-unit entry to the audit ledger with findings `socialite-01` through `socialite-27`, rejected concerns, implementation, validation, performance, and Laravel-facing result; +- add `support-34` to the completed Support entry; +- add `object-pool-04` to the completed Object Pool entry; +- add `reverb-40` to the completed Reverb entry; +- add dependency-index rows for those three cross-package findings and revalidate `support-02`; +- record `socialite-04` as a current Laravel Socialite GitLab defect and preserve the focused source/test delta needed for an upstream correction; +- mark Socialite complete and clear/update the active routing entry only after all gates, self-review, code review, owner checkpoint, and bookkeeping are complete. + +## Regression plan + +Run each changed test file immediately. The final focused coverage must include: + +- `SocialiteManagerTest`: one custom creator visible through Factory and concrete manager; direct manager remains real after Factory facade fake; builder/config types; request refresh and missing-context failure; +- `AbstractProviderTest` / `OAuthTwoTest`: key `"0"`, boot/request config boundaries, recycled-object-ID isolation, parser matrices including zero-padded expiry, intentional missing-token failure, refresh-token retention, direct complete-response publication, whole-response user mapping, transactional user memoization, and strict token results; +- `SocialiteFakeTest`: current OAuth2 `User::fake()` defaults/overrides and enum routing; +- `BitbucketProviderTest`, `GitlabProviderTest`, and `OpenIdProviderTest`: exact Bearer request shapes with no token query; +- `LinkedInProviderTest`: missing `StillImage` on each searched size without warnings; +- `GoogleProviderIdTokenTest`, `FacebookProviderTest`, and `OpenIdProviderTest`: scalar/list/string-configured trusted audiences, issuer classes without Google's catch-all flattening, required OIDC ID-token failure, signature/kid rotation recovery, OIDC complete-response publication, and provider-specific JWKS use; +- generic OIDC discovery coverage: request-local base-URL changes never reuse another tenant's discovery document, refresh reaches the network, and malformed/failing data does not replace a valid entry; +- shared JWKS coverage: reuse before `max-age`, refetch after expiry, repeated/comma-separated directives, zero-padded and malformed max-age handling, immediate staleness for `no-cache` and `no-store`, indefinite headerless reuse plus failure refresh, exact-URL switching, same-URL failed-refresh cooldown with no discovery request, cold/changed-URL failure retry, refreshed discovery changing the JWKS URL, malformed response not published, local-before-atomic-assignment behavior, no refresh for a token without `kid`, and rotation recovery for generic OIDC, Google, and Facebook where each rotated key causes exactly one refetch and a successful second decode; +- OIDC tests: enabled and disabled nonce validation, nonce consumption, previous discovery failure, runtime exception taxonomy, and non-null user response; +- derived reflection coverage for every sensitive parameter, new provider overrides without inventory changes, and corrected native return contracts; +- `tests/Support/ManagerTest.php`: `setContainer()` refreshes config as well as container; +- Object Pool provider coverage: pool created through concrete manager is visible through Factory; interval set through concrete recycler is visible through Recycler; +- Reverb provider coverage: channel created through `ArrayChannelManager` is visible through `ChannelManager`, while an application binding before or after registration still wins; +- split/root dependency validation and facade-documenter lint. + +Then run focused Socialite, Support, Object Pool, and Reverb suites; `composer validate --strict` and the Socialite split validation; documentation link/navigation checks; stale-symbol/import scans; and one authoritative `composer fix` checkpoint. After review amendments, rerun affected focused tests and repeat the full gate only when the changes can affect it. + +## Performance, compatibility, and complexity gates + +- Ordinary non-Socialite requests are unchanged. +- Provider construction pays one local integer increment; each instance caches the resulting namespace. +- Callback paths add only bounded array/string/type checks beside unavoidable network and JWT work. +- JWKS caching removes repeated Google/Facebook network calls and preserves generic OIDC headerless reuse; it retains exactly one key set and one refresh timestamp per cached provider. +- A throttled forced JWKS retry returns the matching cached keys before refreshed discovery, so the cooldown path performs no HTTP work. +- No lock, yield, timer, background job, framework cache, unbounded tenant map, LRU, registry, clone, container lookup, or new ordinary network round trip is added. +- `#[SensitiveParameter]` and native type metadata add no meaningful normal-path work. + +Laravel-facing OAuth2 APIs remain unless a cleaner Hypervel design has an explicit owner gate. Before source implementation, obtain approval for: public raw context helpers becoming protected; the retained Request property becoming `getRequest()` context access; the three protected JWKS methods becoming the concern's `getJwksUri()` / `decodeUsingJwks()` surface; removal of documented OAuth1-only `formatConfig()`; and the additive `User::fake()`, parser/whole-response mapping, full-response, and `trusted_audiences` surfaces. These changes improve coroutine safety or first-party extensibility without compatibility shims. Exception-base changes affect Hypervel-original classes only. `Token::$expiresIn` widens safely to `?int`; supported named arguments and useful Laravel OAuth2 behavior remain intact. + +## Explicit rejections + +Do not add provider/event registries, a mutable config DTO/retriever, provider allowlists, OAuth1 compatibility, a shared mutable Guzzle client, provider/per-coroutine clones, UUID/WeakMap context identity, state/nonce rollback transactions, overlapping-flow registries, encrypted stateless transport, locks/singleflight, maps/LRU eviction, timers/background refresh, framework/PSR cache adapters, Firebase `CachedKeySet`, configurable token transport, a response pipeline, a new exception hierarchy, a creator-result guard, or dual fake swaps. Do not rewrite stateless container bindings for symmetry. Harmless duplicate cold JWKS fetches are accepted. + +## Completion review + +Freshly trace every changed caller/callee and same-family override after implementation. Confirm exact request/coroutine/worker ownership, nested and exceptional cleanup, cache identity/expiry/rotation, HTTP request shapes, JWT failure classes, facade generation, named arguments, ecosystem-port guidance, package metadata, the GitLab upstream handoff, and completed-package records. Search for stale `HasProviderContext`, `$this->request`, `OpenIdProvider::user()`, `formatConfig`, `appendOIDCPayload`, old JWKS helpers, Google's `Failed to verify Google JWT token` wrapper/import, phpseclib, query-token sites, broad `mixed`/nullable response types, false facade methods, and obsolete docs. Reject any new complexity without a demonstrated job and remove every superseded path before code review. From beec54025c2190ce100894b4e395f7aae17c9632 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:43:53 +0000 Subject: [PATCH 11/14] Bound headerless Socialite JWKS reuse Apply a five-minute fallback when an identity provider omits usable cache directives so revoked signing keys cannot remain trusted for the worker lifetime. Keep explicit no-cache, no-store, and max-age directives authoritative, tighten the cached expiry shape, and retain the existing forced-refresh cooldown behavior.\n\nAdd deterministic coverage for both fallback caching and expiry, consolidate the repeated RSA/JWK test fixtures without caching key material, stabilize the Facebook unknown-key assertion, and remove the no-op OpenID test override. Document the observable fallback cadence for custom OpenID Connect providers. --- src/boost/docs/socialite.md | 2 +- .../src/Two/Concerns/InteractsWithJwks.php | 13 ++-- tests/Socialite/FacebookProviderTest.php | 43 ++---------- .../Fixtures/CreatesJwksFixtures.php | 46 +++++++++++++ .../Fixtures/OpenIdTestProviderStub.php | 5 -- .../VerifyingOpenIdTestProviderStub.php | 5 ++ tests/Socialite/GoogleProviderIdTokenTest.php | 41 +----------- tests/Socialite/OpenIdProviderTest.php | 65 +++++++------------ 8 files changed, 90 insertions(+), 130 deletions(-) create mode 100644 tests/Socialite/Fixtures/CreatesJwksFixtures.php diff --git a/src/boost/docs/socialite.md b/src/boost/docs/socialite.md index 9c9a19fe1..a4e7bcd6d 100644 --- a/src/boost/docs/socialite.md +++ b/src/boost/docs/socialite.md @@ -375,7 +375,7 @@ class AcmeOpenIdProvider extends OpenIdProvider implements ProviderInterface } ``` -The base provider discovers the authorization, token, UserInfo, and JSON Web Key Set endpoints. UserInfo requests use Bearer authorization, and signing keys are reused according to the provider's cache directives and refreshed once when a provider rotates them. +The base provider discovers the authorization, token, UserInfo, and JSON Web Key Set endpoints. UserInfo requests use Bearer authorization, and signing keys are reused according to the provider's cache directives, or for five minutes when none are provided, and refreshed once when a provider rotates them. An ID token must include the configured client ID in its audience. If the provider also includes audiences for your APIs or other trusted services, list them using the `trusted_audiences` configuration option: diff --git a/src/socialite/src/Two/Concerns/InteractsWithJwks.php b/src/socialite/src/Two/Concerns/InteractsWithJwks.php index c07f4e6f7..8286d344c 100644 --- a/src/socialite/src/Two/Concerns/InteractsWithJwks.php +++ b/src/socialite/src/Two/Concerns/InteractsWithJwks.php @@ -16,7 +16,7 @@ trait InteractsWithJwks /** * The parsed JSON Web Key Set for the current URI. * - * @var null|array{url: string, keys: array, expiresAt: null|int} + * @var null|array{url: string, keys: array, expiresAt: int} */ protected ?array $jwks = null; @@ -32,6 +32,11 @@ trait InteractsWithJwks */ protected int $jwksRefreshCooldownSeconds = 10; + /** + * The fallback lifetime for JWKS responses without cache directives. + */ + protected int $jwksDefaultTtlSeconds = 300; + /** * Get the JSON Web Key Set URI for the provider. */ @@ -65,7 +70,7 @@ private function getJwks(bool $refresh = false): array if (! $refresh && ($this->jwks['url'] ?? null) === $url - && ($this->jwks['expiresAt'] === null || $now < $this->jwks['expiresAt'])) { + && $now < $this->jwks['expiresAt']) { return $this->jwks['keys']; } @@ -104,7 +109,7 @@ private function getJwks(bool $refresh = false): array /** * Get the expiration timestamp from the response cache directives. */ - private function getJwksExpiresAt(ResponseInterface $response, int $now): ?int + private function getJwksExpiresAt(ResponseInterface $response, int $now): int { $maxAge = null; @@ -138,6 +143,6 @@ private function getJwksExpiresAt(ResponseInterface $response, int $now): ?int } } - return $maxAge === null ? null : $now + $maxAge; + return $now + ($maxAge ?? $this->jwksDefaultTtlSeconds); } } diff --git a/tests/Socialite/FacebookProviderTest.php b/tests/Socialite/FacebookProviderTest.php index e54137db6..9fd0e9bfc 100644 --- a/tests/Socialite/FacebookProviderTest.php +++ b/tests/Socialite/FacebookProviderTest.php @@ -12,6 +12,7 @@ use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Socialite\Two\FacebookProvider; use Hypervel\Socialite\Two\User; +use Hypervel\Tests\Socialite\Fixtures\CreatesJwksFixtures; use Hypervel\Tests\TestCase; use Mockery as m; use ReflectionMethod; @@ -19,6 +20,8 @@ class FacebookProviderTest extends TestCase { + use CreatesJwksFixtures; + public function testMapUserToObjectWithAccessTokenResponse(): void { $provider = $this->getProvider(); @@ -108,7 +111,7 @@ public function testAnUnknownKidRaisesTheLibraryAuthenticationFailure(): void $this->expectJwksResponses($provider, [$knownKey, $knownKey]); $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage('"kid" invalid, unable to lookup correct key'); + $this->expectExceptionMessage('"kid" invalid'); $provider->userFromToken($this->createSignedToken($unknownKey)); } @@ -179,42 +182,4 @@ private function createSignedToken( 'exp' => time() + 3600, ], $key['private'], 'RS256', $key['kid']); } - - private function createRsaKeyPair(string $kid): array - { - $key = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - if ($key === false) { - $this->fail('Unable to generate RSA key pair for Facebook ID token test.'); - } - - openssl_pkey_export($key, $privateKey); - $details = openssl_pkey_get_details($key); - - return [ - 'kid' => $kid, - 'private' => $privateKey, - 'jwk' => [ - 'kid' => $kid, - 'kty' => 'RSA', - 'use' => 'sig', - 'alg' => 'RS256', - 'n' => $this->base64UrlEncode($details['rsa']['n']), - 'e' => $this->base64UrlEncode($details['rsa']['e']), - ], - ]; - } - - private function jwks(array $key): array - { - return ['keys' => [$key['jwk']]]; - } - - private function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } } diff --git a/tests/Socialite/Fixtures/CreatesJwksFixtures.php b/tests/Socialite/Fixtures/CreatesJwksFixtures.php new file mode 100644 index 000000000..493bb7d43 --- /dev/null +++ b/tests/Socialite/Fixtures/CreatesJwksFixtures.php @@ -0,0 +1,46 @@ + 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($key === false) { + $this->fail('Unable to generate RSA key pair for Socialite test.'); + } + + openssl_pkey_export($key, $privateKey); + $details = openssl_pkey_get_details($key); + + return [ + 'kid' => $kid, + 'private' => $privateKey, + 'jwk' => [ + 'kid' => $kid, + 'kty' => 'RSA', + 'use' => 'sig', + 'alg' => 'RS256', + 'n' => $this->base64UrlEncode($details['rsa']['n']), + 'e' => $this->base64UrlEncode($details['rsa']['e']), + ], + ]; + } + + private function jwks(array $key): array + { + return ['keys' => [$key['jwk']]]; + } + + private function base64UrlEncode(string $value): string + { + return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); + } +} diff --git a/tests/Socialite/Fixtures/OpenIdTestProviderStub.php b/tests/Socialite/Fixtures/OpenIdTestProviderStub.php index 12ddb46a7..f8279580c 100644 --- a/tests/Socialite/Fixtures/OpenIdTestProviderStub.php +++ b/tests/Socialite/Fixtures/OpenIdTestProviderStub.php @@ -29,11 +29,6 @@ protected function getTokenUrl(): string return 'http://token.url'; } - protected function getUserByToken(#[SensitiveParameter] string $token): array - { - return parent::getUserByToken($token); - } - /** * Get user based on the OIDC token. */ diff --git a/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php b/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php index 203f22999..fe2cc1f8a 100644 --- a/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php +++ b/tests/Socialite/Fixtures/VerifyingOpenIdTestProviderStub.php @@ -26,6 +26,11 @@ public function setJwksRefreshCooldownSeconds(int $seconds): void $this->jwksRefreshCooldownSeconds = $seconds; } + public function setJwksDefaultTtlSeconds(int $seconds): void + { + $this->jwksDefaultTtlSeconds = $seconds; + } + protected function getBaseUrl(): string { return $this->getConfig('base_url', 'http://base.url'); diff --git a/tests/Socialite/GoogleProviderIdTokenTest.php b/tests/Socialite/GoogleProviderIdTokenTest.php index 093f151c4..726c2983f 100644 --- a/tests/Socialite/GoogleProviderIdTokenTest.php +++ b/tests/Socialite/GoogleProviderIdTokenTest.php @@ -13,6 +13,7 @@ use Hypervel\Socialite\Two\Exceptions\InvalidIssuerException; use Hypervel\Socialite\Two\GoogleProvider; use Hypervel\Socialite\Two\User; +use Hypervel\Tests\Socialite\Fixtures\CreatesJwksFixtures; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -22,6 +23,8 @@ class GoogleProviderIdTokenTest extends TestCase { + use CreatesJwksFixtures; + public function testItCanDetectJwtTokens(): void { $provider = $this->getProvider(); @@ -227,42 +230,4 @@ private function createSignedToken( 'exp' => time() + 3600, ], $key['private'], 'RS256', $key['kid']); } - - private function createRsaKeyPair(string $kid): array - { - $key = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - if ($key === false) { - $this->fail('Unable to generate RSA key pair for Google ID token test.'); - } - - openssl_pkey_export($key, $privateKey); - $details = openssl_pkey_get_details($key); - - return [ - 'kid' => $kid, - 'private' => $privateKey, - 'jwk' => [ - 'kid' => $kid, - 'kty' => 'RSA', - 'use' => 'sig', - 'alg' => 'RS256', - 'n' => $this->base64UrlEncode($details['rsa']['n']), - 'e' => $this->base64UrlEncode($details['rsa']['e']), - ], - ]; - } - - private function jwks(array $key): array - { - return ['keys' => [$key['jwk']]]; - } - - private function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } } diff --git a/tests/Socialite/OpenIdProviderTest.php b/tests/Socialite/OpenIdProviderTest.php index d69f94831..b89cdf861 100644 --- a/tests/Socialite/OpenIdProviderTest.php +++ b/tests/Socialite/OpenIdProviderTest.php @@ -15,6 +15,7 @@ use Hypervel\Socialite\Two\Exceptions\InvalidNonceException; use Hypervel\Socialite\Two\Exceptions\InvalidUserInfoUrlException; use Hypervel\Socialite\Two\User; +use Hypervel\Tests\Socialite\Fixtures\CreatesJwksFixtures; use Hypervel\Tests\Socialite\Fixtures\OpenIdTestProviderStub; use Hypervel\Tests\Socialite\Fixtures\VerifyingOpenIdTestProviderStub; use Hypervel\Tests\TestCase; @@ -29,6 +30,8 @@ class OpenIdProviderTest extends TestCase { + use CreatesJwksFixtures; + public function testRedirectGeneratesTheProperRedirectResponseWithoutPKCE(): void { $request = m::mock(Request::class); @@ -584,7 +587,7 @@ public function testOidcJwksUsesTheSmallestRepeatedMaxAge(): void $this->addToAssertionCount(1); } - public function testOidcJwksIgnoresMalformedAndOverflowingMaxAgeValues(): void + public function testOidcJwksFallsBackToDefaultTtlForMalformedAndOverflowingMaxAgeValues(): void { $key = $this->createRsaKeyPair('malformed-max-age'); $provider = $this->createVerifyingProvider(); @@ -604,6 +607,24 @@ public function testOidcJwksIgnoresMalformedAndOverflowingMaxAgeValues(): void $this->addToAssertionCount(1); } + public function testOidcJwksUsesDefaultTtlWhenCacheDirectivesAreMissing(): void + { + $key = $this->createRsaKeyPair('default-ttl-key'); + $provider = $this->createVerifyingProvider(); + $provider->setJwksDefaultTtlSeconds(0); + + $this->expectOpenIdConfigRequests($provider->http, 1); + $this->expectJwksRequests($provider->http, [ + $this->jwks($key), + $this->jwks($key), + ]); + + $token = $this->createSignedToken($key); + + $this->assertSame('foo', $provider->verifyToken($token)['sub']); + $this->assertSame('foo', $provider->verifyToken($token)['sub']); + } + public function testOidcJwksSwitchesWithTheExactDiscoveryUrl(): void { $tenantAKey = $this->createRsaKeyPair('tenant-a-key'); @@ -805,46 +826,4 @@ private function createSignedToken( 'exp' => time() + 3600, ], $key['private'], 'RS256', $includeKid ? $key['kid'] : null); } - - private function createRsaKeyPair(string $kid): array - { - $key = openssl_pkey_new([ - 'private_key_bits' => 2048, - 'private_key_type' => OPENSSL_KEYTYPE_RSA, - ]); - - if ($key === false) { - $this->fail('Unable to generate RSA key pair for OIDC test.'); - } - - openssl_pkey_export($key, $privateKey); - $details = openssl_pkey_get_details($key); - - return [ - 'kid' => $kid, - 'private' => $privateKey, - 'jwk' => [ - 'kid' => $kid, - 'kty' => 'RSA', - 'use' => 'sig', - 'alg' => 'RS256', - 'n' => $this->base64UrlEncode($details['rsa']['n']), - 'e' => $this->base64UrlEncode($details['rsa']['e']), - ], - ]; - } - - private function jwks(array $key): array - { - return [ - 'keys' => [ - $key['jwk'], - ], - ]; - } - - private function base64UrlEncode(string $value): string - { - return rtrim(strtr(base64_encode($value), '+/', '-_'), '='); - } } From 31efddeeee2dac7581c454cae5a9cc6fbc913537 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:44:04 +0000 Subject: [PATCH 12/14] Preserve Socialite provider subtype behavior Construct OAuth 2 fake users with late static binding so ecosystem-specific User subclasses receive instances of the called class instead of the base Socialite user. Cover the inherited factory directly.\n\nStrengthen LinkedIn avatar mapping coverage with a later unrelated image so the regression continues to prove exact 100px and 800px selection rather than accidentally accepting the final image. --- src/socialite/src/Two/User.php | 4 ++-- tests/Socialite/LinkedInProviderTest.php | 1 + tests/Socialite/SocialiteFakeTest.php | 9 +++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/socialite/src/Two/User.php b/src/socialite/src/Two/User.php index 40a6ab361..9df9190cc 100644 --- a/src/socialite/src/Two/User.php +++ b/src/socialite/src/Two/User.php @@ -37,7 +37,7 @@ class User extends AbstractUser /** * Create a fake OAuth 2 user instance. */ - public static function fake(#[SensitiveParameter] array $attributes = []): self + public static function fake(#[SensitiveParameter] array $attributes = []): static { $attributes = array_merge([ 'id' => '123456789', @@ -52,7 +52,7 @@ public static function fake(#[SensitiveParameter] array $attributes = []): self 'accessTokenResponseBody' => [], ], $attributes); - return (new self)->setRaw($attributes)->map($attributes) + return (new static)->setRaw($attributes)->map($attributes) ->setToken($attributes['token']) ->setRefreshToken($attributes['refreshToken']) ->setExpiresIn($attributes['expiresIn']) diff --git a/tests/Socialite/LinkedInProviderTest.php b/tests/Socialite/LinkedInProviderTest.php index 2c8957954..d35a0206e 100644 --- a/tests/Socialite/LinkedInProviderTest.php +++ b/tests/Socialite/LinkedInProviderTest.php @@ -115,6 +115,7 @@ public function testMapUserSkipsImagesWithoutStillImageMetadata(): void $image(100, 'https://example.com/avatar.jpg'), ['data' => [], 'identifiers' => []], $image(800, 'https://example.com/avatar-original.jpg'), + $image(1200, 'https://example.com/unrelated-image.jpg'), ], ], ], diff --git a/tests/Socialite/SocialiteFakeTest.php b/tests/Socialite/SocialiteFakeTest.php index d9efb102b..ddc16a058 100644 --- a/tests/Socialite/SocialiteFakeTest.php +++ b/tests/Socialite/SocialiteFakeTest.php @@ -19,6 +19,10 @@ enum SocialiteFakeTestIntIdentifier: int case Zero = 0; } +class SocialiteFakeTestUser extends OAuth2User +{ +} + class SocialiteFakeTest extends TestCase { protected function getPackageProviders($app): array @@ -105,6 +109,11 @@ public function testOAuthTwoUserFakeHasDefaultsAndAcceptsOverrides(): void $this->assertSame(['token_type' => 'Bearer'], $overridden->accessTokenResponseBody); } + public function testOAuthTwoUserFakeUsesLateStaticBinding(): void + { + $this->assertInstanceOf(SocialiteFakeTestUser::class, SocialiteFakeTestUser::fake()); + } + public function testItReturnsFakeRedirectResponse(): void { Socialite::fake('github', (new OAuth2User)->map(['id' => '123'])); From b79d85f8e62ee37291a668c586594b9d8c089948 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:44:10 +0000 Subject: [PATCH 13/14] Record bounded Socialite key freshness Replace the superseded indefinite-headerless JWKS design with the reviewed five-minute provider fallback in the Socialite plan and audit ledger. Record the non-null cache shape, deterministic regression boundary, performance effect, and late-static User fake divergence without retaining rejected reasoning or decision history. --- ...-coroutine-state-lifecycle-audit-ledger.md | 8 +++---- ...first-party-extensibility-and-lifecycle.md | 22 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index c68da7083..0b6ec4c96 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1947,12 +1947,12 @@ Append package entries in checklist order. Keep each entry compact but complete | Findings | Final decision | |---|---| | `socialite-01`, `socialite-04`, `socialite-06` | Move Bitbucket, GitLab, and generic OIDC user credentials from URLs to Bearer headers; use GitLab's current `/api/v4/user` endpoint while retaining Facebook's documented query-token request. | -| `socialite-02`, `socialite-03` | Port current LinkedIn optional-image handling and OAuth2 `User::fake()` behavior with strict Hypervel types and coverage. | +| `socialite-02`, `socialite-03` | Port current LinkedIn optional-image handling and OAuth2 `User::fake()` behavior with strict Hypervel types, exact-size coverage, and late-static construction for ecosystem user subclasses. | | `socialite-05`, `socialite-21`, `socialite-25`, `socialite-26` | Validate exact Google issuers and complete scalar/list audiences, consume nonce once only when enabled, preserve discovery causes, and use runtime exceptions for operational metadata failures. | | `socialite-07`, `socialite-09` | Delete dead OIDC payload and OAuth1-only formatting paths; record the intentional OAuth1/legacy Twitter omission and `x` replacement at natural sync and documentation surfaces. | | `socialite-10`, `socialite-11`, `socialite-12`, `socialite-22`, `socialite-23` | Separate boot configuration from coroutine-local request overrides, protect provider context internals, preserve null and `"0"` config keys, use non-recyclable provider namespaces, and keep request ownership in coroutine context. | | `socialite-13`, `socialite-14`, `socialite-17`, `socialite-18` | Make provider/response types and facade metadata truthful, redact every secret-bearing frame, and give Factory and concrete manager resolutions one worker-lifetime owner while preserving Factory-only fakes. | -| `socialite-19` | Use one bounded exact-URL JWKS concern across generic OIDC, Google, and Facebook, with cache-directive expiry, local-before-publication parsing, and one throttled rotation retry; remove manual RSA construction and phpseclib. | +| `socialite-19` | Use one bounded exact-URL JWKS concern across generic OIDC, Google, and Facebook, with cache-directive expiry, a provider-tunable five-minute fallback, local-before-publication parsing, and one throttled rotation retry; remove manual RSA construction and phpseclib. | | `socialite-20`, `socialite-24`, `socialite-27` | Centralize token response parsers and whole-response mapping, preserve refresh tokens, publish the complete response directly on the returned user, and cache a user only after every parser and setter succeeds. | | `socialite-15`, `socialite-16` | Document stateless session limits, dynamic redirect/callback configuration, custom OAuth2/OIDC providers, parser hooks, request access, complete token responses, registration, and testing in Laravel-docs prose. | | `socialite-08` | Keep the direct Collections dependency because Socialite uses its `Arr` and `value()` symbols. | @@ -1962,7 +1962,7 @@ Append package entries in checklist order. Keep each entry compact but complete - **Correctness, security, and parity:** OAuth2 Laravel-facing APIs, named arguments, protected extension points, and provider ergonomics remain compatible. Hypervel retains its approved OAuth1/legacy Twitter omission and `x` driver, while its additive custom-provider, dynamic-config, parser, complete-response, and trusted-audience surfaces make first-party extension packages unnecessary for ordinary OAuth2/OIDC providers. Provider credentials no longer appear in corrected URLs, OIDC validation is exact without imposing a universal `azp`, discovery and key rotation preserve failure causes, and caught exchange failures cannot return a partial cached user. - **Important rejected concerns:** No provider or event registry, mutable config DTO, provider allowlist, OAuth1 compatibility layer, shared mutable client, per-coroutine provider clone, UUID or WeakMap namespace, state/nonce rollback system, overlapping-flow registry, encrypted stateless transport, lock, singleflight, tenant map, LRU, timer, background refresh, framework cache adapter, Firebase `CachedKeySet`, configurable token transport, response pipeline, exception hierarchy, creator-result guard, or dual fake swap was added. Stateless container bindings were not rewritten for symmetry, and harmless duplicate cold JWKS fetches remain accepted. - **Upstream handoff:** `socialite-04` is also present in current Laravel Socialite: GitLab user lookup sends the access token in the query. The minimal upstream correction is the provider request change to `/api/v4/user` with Bearer authorization plus the focused request-shape regression; it requires no Hypervel-specific lifecycle adaptation. -- **Regression coverage:** Tests prove exact Bearer request shapes, dynamic config isolation and rebinding, request ownership, non-recyclable provider context identity, Factory/concrete identity and fake behavior, parser matrices and zero-padded protocol integers, refresh retention, transactional user publication, complete response mapping, sensitive parameters, OIDC discovery/audience/issuer/nonce behavior, bounded JWKS expiry/rotation/failure paths across all three providers, current user fakes and LinkedIn mapping, facade and package metadata, and the three cross-package container identities. -- **Performance and complexity:** Ordinary non-Socialite requests are unchanged. Provider construction adds one non-yielding integer increment, request paths add bounded local array/string checks beside existing network work, and JWKS reuse removes repeated network requests while retaining one key set and one refresh timestamp per provider. No request path gains a lock, timer, background job, registry, unbounded map, clone, container lookup, serialization layer, or additional ordinary network round trip. +- **Regression coverage:** Tests prove exact Bearer request shapes, dynamic config isolation and rebinding, request ownership, non-recyclable provider context identity, Factory/concrete identity and fake behavior, parser matrices and zero-padded protocol integers, refresh retention, transactional user publication, complete response mapping, sensitive parameters, OIDC discovery/audience/issuer/nonce behavior, bounded directive and headerless JWKS expiry/rotation/failure paths across all three providers, late-static user fakes, exact-size LinkedIn mapping, facade and package metadata, and the three cross-package container identities. +- **Performance and complexity:** Ordinary non-Socialite requests are unchanged. Provider construction adds one non-yielding integer increment, request paths add bounded local array/string checks beside existing network work, and JWKS reuse removes repeated network requests while bounding headerless reuse to five minutes by default and retaining one key set and one refresh timestamp per provider. No request path gains a lock, timer, background job, registry, unbounded map, clone, container lookup, serialization layer, or additional ordinary network round trip. - **Validation and review:** Changed tests passed during implementation; focused Socialite, Support, Object Pool, and Reverb coverage, root and split Composer validation, facade and documentation checks, stale-symbol scans, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, and `git diff --check` passed. Review independently reproduced the provider-namespace collision, stale config rebinding, nonce-disabled failure, partial-user memoization, and response-state risks, then signed off after every source and plan correction landed. - **Assessment:** Socialite is coroutine-safe, worker-lifecycle-aware, protocol-correct, current at the supported Laravel surface, and first-party extensible without ecosystem-manager machinery. Every accepted finding is fixed at its lowest owner; no stale response state, compatibility workaround, speculative abstraction, unresolved accepted defect, meaningful performance regression, or deferred TODO remains. diff --git a/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md b/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md index 2e27ed9b1..e41efb7de 100644 --- a/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md +++ b/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md @@ -55,7 +55,7 @@ Record low-confidence concerns under rejected or unresolved analysis. Do not imp |---|---|---| | `socialite-01` | Security defect / Major | Send both Bitbucket user and email tokens in `Authorization: Bearer`; never place them in URLs. | | `socialite-02` | Upstream defect / Minor | Port current LinkedIn `StillImage` handling: hoist the optional node to `[]` and terminate width reads with `?? null`. | -| `socialite-03` | Current parity improvement | Port OAuth2 `Two\User::fake()` and current focused tests/docs; OAuth1 remains unsupported. | +| `socialite-03` | Current parity improvement | Port OAuth2 `Two\User::fake()` and current focused tests/docs, using late-static construction for ecosystem user subclasses; OAuth1 remains unsupported. | | `socialite-04` | Provider/security defect / Major | Use GitLab `/api/v4/user` and Bearer auth. This also corrects current Laravel Socialite behavior. | | `socialite-05` | JWT validation defect / Major | Accept exactly Google's bare and HTTPS issuer forms; use the package's named issuer/audience exceptions without flattening their cause. | | `socialite-06` | Security defect / Major | Send generic OIDC UserInfo tokens through Bearer auth while retaining the JSON accept header. | @@ -71,7 +71,7 @@ Record low-confidence concerns under rejected or unresolved analysis. Do not imp | `socialite-16` | Extension documentation gap / Minor | Document `OpenIdProvider`, custom OAuth2 providers, parser hooks, request access, full token responses, and provider registration in Laravel-docs prose. | | `socialite-17` | Secret-handling defect / Major | Apply `#[SensitiveParameter]` to every active client-secret, token, code, ID-token, secret config, and token-response frame. Keep user profiles, client IDs, state, nonce, and scopes diagnostic, and derive reflection coverage from the provider surface rather than maintaining a duplicate method inventory. | | `socialite-18` | Container identity defect / Major | Make `SocialiteManager` the canonical auto-singleton and alias Factory to it. Preserve Factory-only facade fake swaps and delete duplicate manager overrides. | -| `socialite-19` | Tenant-correctness/performance/availability defect / Major | Key generic OIDC discovery by its exact URL and use one bounded exact-URL parsed-JWKS concern for generic OIDC, Google, and Facebook, including cache directives and one throttled rotation retry. Remove manual Facebook RSA construction and `phpseclib`. | +| `socialite-19` | Tenant-correctness/performance/availability defect / Major | Key generic OIDC discovery by its exact URL and use one bounded exact-URL parsed-JWKS concern for generic OIDC, Google, and Facebook, including cache directives, a five-minute fallback, and one throttled rotation retry. Remove manual Facebook RSA construction and `phpseclib`. | | `socialite-20` | OAuth response defect / Major | Preserve the submitted refresh token when rotation omits one; add four protected response parsers and a whole-response user-mapping seam, use nullable exact expiry parsing, and remove redundant Google/Twitch/OIDC orchestration. | | `socialite-21` | OIDC validation defect / Major | Accept scalar/list audiences, require this client ID, reject additional audiences unless listed in `trusted_audiences`, and do not universally require `azp`. Apply to generic OIDC, Google, and Facebook. | | `socialite-22` | Coroutine context identity defect / Major | Replace recyclable object-ID namespaces with a lazy monotonic process-lifetime sequence that is intentionally never reset. | @@ -274,7 +274,7 @@ protected function userInstance(#[SensitiveParameter] array $response, array $us } ``` -Port `Two\User::fake(array $attributes = [])` from current upstream, adapted to strict Hypervel types. Include `accessTokenResponseBody` with a default empty array, pass an override through its setter, and cover both behaviors. +Port `Two\User::fake(array $attributes = [])` from current upstream, adapted to strict Hypervel types and late-static construction so ecosystem subclasses return the called class. Include `accessTokenResponseBody` with a default empty array, pass an override through its setter, and cover both behaviors. ### 4. Secure provider transport and OIDC validation @@ -347,13 +347,15 @@ The implementation must derive the current URL before reuse, fetch/decode into a Add `src/socialite/src/Two/Concerns/InteractsWithJwks.php` and use it from generic OIDC, Google, and Facebook. The concern owns: ```php -/** @var null|array{url: string, keys: array, expiresAt: ?int} */ +/** @var null|array{url: string, keys: array, expiresAt: int} */ protected ?array $jwks = null; /** @var null|array{url: string, attemptedAt: int} */ protected ?array $jwksRefreshAttempt = null; protected int $jwksRefreshCooldownSeconds = 10; + +protected int $jwksDefaultTtlSeconds = 300; ``` Keep the concern's key-fetch/cache helpers private. The only per-provider override is `protected function getJwksUri(bool $refresh = false): string`: generic OIDC refreshes discovery when requested, while Google and Facebook return fixed URLs. The concern also owns one shared protected decode boundary: @@ -367,7 +369,7 @@ It fetches the parsed keys, calls `JWT::decode()`, and performs the single throt The fetch algorithm must: 1. derive the lookup URL with `getJwksUri(false)` before consulting the one-entry cache; -2. on an ordinary read, return matching keys while `expiresAt === null` or the current `time()` is before expiry; +2. on an ordinary read, return matching keys while the current `time()` is before expiry; 3. on a forced read, return matching cached keys when the refresh-attempt entry matches the lookup URL and remains inside the cooldown, without calling `getJwksUri(true)` or performing any HTTP request; 4. otherwise stamp the lookup URL with one captured `time()` before refresh I/O, call `getJwksUri(true)`, and replace the stamp's URL with the refreshed URL before the JWKS request if discovery changed it; 5. fetch, throwing-decode, and `JWK::parseKeySet()` into locals, then atomically assign the complete refreshed URL/keys/expiry entry; @@ -375,9 +377,9 @@ The fetch algorithm must: Replacing the stamp URL aligns a successful newly discovered key set with later cooldown checks. Stamping before I/O also throttles a same-URL discovery or JWKS failure when matching cached keys remain. A cold failure, or a changed-URL failure with no matching keys, deliberately retries on the next login because the cooldown has no correct keys it can return; do not add a second failure-only timestamp or suppression path. -Parse every `Cache-Control` header value and every comma-separated directive case-insensitively. `no-cache` or `no-store` anywhere wins and makes the entry immediately stale. Accept `max-age=N` only when `N` is a non-negative decimal integer no greater than `PHP_INT_MAX - $now`, including zero-padded values allowed by HTTP's `1*DIGIT` grammar; when repeated valid values exist, use the smallest. Normalize leading zeroes locally before `FILTER_VALIDATE_INT`. Ignore malformed, negative, and out-of-range max-age values. If no usable directive remains, retain the entry without expiry, preserving generic OIDC's current cache-until-failure behavior. Deliberately ignore `Expires`. +Parse every `Cache-Control` header value and every comma-separated directive case-insensitively. `no-cache` or `no-store` anywhere wins and makes the entry immediately stale. Accept `max-age=N` only when `N` is a non-negative decimal integer no greater than `PHP_INT_MAX - $now`, including zero-padded values allowed by HTTP's `1*DIGIT` grammar; when repeated valid values exist, use the smallest. Normalize leading zeroes locally before `FILTER_VALIDATE_INT`. Ignore malformed, negative, and out-of-range max-age values. If no usable directive remains, use the provider-tunable five-minute fallback so revoked headerless keys cannot remain trusted for the worker lifetime. Deliberately ignore `Expires`. -Use `time()` for both expiry and cooldown. A wall-clock jump may shorten or extend reuse, but signature/kid failure still forces refresh; mixing `hrtime()` with protocol timestamps would add complexity without improving the contract. Headerless retention is safe only because forced refresh lands in the same change. +Use `time()` for both expiry and cooldown. A wall-clock jump may shorten or extend reuse, but the fallback bounds headerless retention and signature/kid failure still forces refresh; mixing `hrtime()` with protocol timestamps would add complexity without improving the contract. Facebook now decodes through the shared boundary, so unknown kids produce the library's authentication failure instead of a null dereference. Remove `phpseclib/phpseclib` from the Socialite split package and, because no other package uses it, remove the root direct dependency and update the lock through Composer. @@ -441,7 +443,7 @@ Run each changed test file immediately. The final focused coverage must include: - `LinkedInProviderTest`: missing `StillImage` on each searched size without warnings; - `GoogleProviderIdTokenTest`, `FacebookProviderTest`, and `OpenIdProviderTest`: scalar/list/string-configured trusted audiences, issuer classes without Google's catch-all flattening, required OIDC ID-token failure, signature/kid rotation recovery, OIDC complete-response publication, and provider-specific JWKS use; - generic OIDC discovery coverage: request-local base-URL changes never reuse another tenant's discovery document, refresh reaches the network, and malformed/failing data does not replace a valid entry; -- shared JWKS coverage: reuse before `max-age`, refetch after expiry, repeated/comma-separated directives, zero-padded and malformed max-age handling, immediate staleness for `no-cache` and `no-store`, indefinite headerless reuse plus failure refresh, exact-URL switching, same-URL failed-refresh cooldown with no discovery request, cold/changed-URL failure retry, refreshed discovery changing the JWKS URL, malformed response not published, local-before-atomic-assignment behavior, no refresh for a token without `kid`, and rotation recovery for generic OIDC, Google, and Facebook where each rotated key causes exactly one refetch and a successful second decode; +- shared JWKS coverage: reuse before `max-age`, refetch after expiry, repeated/comma-separated directives, zero-padded and malformed max-age handling, immediate staleness for `no-cache` and `no-store`, bounded headerless reuse, exact-URL switching, same-URL failed-refresh cooldown with no discovery request, cold/changed-URL failure retry, refreshed discovery changing the JWKS URL, malformed response not published, local-before-atomic-assignment behavior, no refresh for a token without `kid`, and rotation recovery for generic OIDC, Google, and Facebook where each rotated key causes exactly one refetch and a successful second decode; - OIDC tests: enabled and disabled nonce validation, nonce consumption, previous discovery failure, runtime exception taxonomy, and non-null user response; - derived reflection coverage for every sensitive parameter, new provider overrides without inventory changes, and corrected native return contracts; - `tests/Support/ManagerTest.php`: `setContainer()` refreshes config as well as container; @@ -456,12 +458,12 @@ Then run focused Socialite, Support, Object Pool, and Reverb suites; `composer v - Ordinary non-Socialite requests are unchanged. - Provider construction pays one local integer increment; each instance caches the resulting namespace. - Callback paths add only bounded array/string/type checks beside unavoidable network and JWT work. -- JWKS caching removes repeated Google/Facebook network calls and preserves generic OIDC headerless reuse; it retains exactly one key set and one refresh timestamp per cached provider. +- JWKS caching removes repeated Google/Facebook network calls and bounds generic OIDC headerless reuse to five minutes by default; it retains exactly one key set and one refresh timestamp per cached provider. - A throttled forced JWKS retry returns the matching cached keys before refreshed discovery, so the cooldown path performs no HTTP work. - No lock, yield, timer, background job, framework cache, unbounded tenant map, LRU, registry, clone, container lookup, or new ordinary network round trip is added. - `#[SensitiveParameter]` and native type metadata add no meaningful normal-path work. -Laravel-facing OAuth2 APIs remain unless a cleaner Hypervel design has an explicit owner gate. Before source implementation, obtain approval for: public raw context helpers becoming protected; the retained Request property becoming `getRequest()` context access; the three protected JWKS methods becoming the concern's `getJwksUri()` / `decodeUsingJwks()` surface; removal of documented OAuth1-only `formatConfig()`; and the additive `User::fake()`, parser/whole-response mapping, full-response, and `trusted_audiences` surfaces. These changes improve coroutine safety or first-party extensibility without compatibility shims. Exception-base changes affect Hypervel-original classes only. `Token::$expiresIn` widens safely to `?int`; supported named arguments and useful Laravel OAuth2 behavior remain intact. +Laravel-facing OAuth2 APIs remain unless a cleaner Hypervel design has an explicit owner gate. Before source implementation, obtain approval for: public raw context helpers becoming protected; the retained Request property becoming `getRequest()` context access; the three protected JWKS methods becoming the concern's `getJwksUri()` / `decodeUsingJwks()` surface; removal of documented OAuth1-only `formatConfig()`; and the additive `User::fake()`, parser/whole-response mapping, full-response, and `trusted_audiences` surfaces. These changes improve coroutine safety or first-party extensibility without compatibility shims. `User::fake()` deliberately uses late-static construction rather than upstream's `new self`, so inherited ecosystem factories return the called subclass. Exception-base changes affect Hypervel-original classes only. `Token::$expiresIn` widens safely to `?int`; supported named arguments and useful Laravel OAuth2 behavior remain intact. ## Explicit rejections From 04a003e62680a09a5908d880e2a07d0ed9cc54c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 18:55:43 +0000 Subject: [PATCH 14/14] Resolve Socialite plan approval state Replace the stale pre-implementation owner gate with the final intentional API decisions now present in the package. Keep the compatibility rationale and Laravel-facing contract summary while removing procedural text that contradicted the completed implementation. --- ...alite-correctness-first-party-extensibility-and-lifecycle.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md b/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md index e41efb7de..8c06cee61 100644 --- a/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md +++ b/docs/plans/2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md @@ -463,7 +463,7 @@ Then run focused Socialite, Support, Object Pool, and Reverb suites; `composer v - No lock, yield, timer, background job, framework cache, unbounded tenant map, LRU, registry, clone, container lookup, or new ordinary network round trip is added. - `#[SensitiveParameter]` and native type metadata add no meaningful normal-path work. -Laravel-facing OAuth2 APIs remain unless a cleaner Hypervel design has an explicit owner gate. Before source implementation, obtain approval for: public raw context helpers becoming protected; the retained Request property becoming `getRequest()` context access; the three protected JWKS methods becoming the concern's `getJwksUri()` / `decodeUsingJwks()` surface; removal of documented OAuth1-only `formatConfig()`; and the additive `User::fake()`, parser/whole-response mapping, full-response, and `trusted_audiences` surfaces. These changes improve coroutine safety or first-party extensibility without compatibility shims. `User::fake()` deliberately uses late-static construction rather than upstream's `new self`, so inherited ecosystem factories return the called subclass. Exception-base changes affect Hypervel-original classes only. `Token::$expiresIn` widens safely to `?int`; supported named arguments and useful Laravel OAuth2 behavior remain intact. +Laravel-facing OAuth2 APIs remain. The intentional API changes are: public raw context helpers become protected; the retained Request property becomes `getRequest()` context access; the three protected JWKS methods become the concern's `getJwksUri()` / `decodeUsingJwks()` surface; documented OAuth1-only `formatConfig()` is removed; and the additive `User::fake()`, parser/whole-response mapping, full-response, and `trusted_audiences` surfaces are introduced. These changes improve coroutine safety or first-party extensibility without compatibility shims. `User::fake()` deliberately uses late-static construction rather than upstream's `new self`, so inherited ecosystem factories return the called subclass. Exception-base changes affect Hypervel-original classes only. `Token::$expiresIn` widens safely to `?int`; supported named arguments and useful Laravel OAuth2 behavior remain intact. ## Explicit rejections