From 89bf059d6502cf8128c2afaae2683186feedd051 Mon Sep 17 00:00:00 2001 From: rennokki Date: Fri, 24 Feb 2023 18:41:55 +0200 Subject: [PATCH] [4.x] Automatically refresh expired tokens (#248) --- UPGRADE.md | 1 - config/socialstream.php | 1 + src/Concerns/RefreshesOauth2Tokens.php | 66 +++++++++ src/ConnectedAccount.php | 8 +- src/Contracts/Credentials.php | 30 +---- src/Contracts/Oauth2RefreshResolver.php | 14 ++ src/Contracts/RefreshedCredentials.php | 36 +++++ src/Credentials.php | 127 ++---------------- src/Features.php | 16 +++ src/HasOauth2Tokens.php | 67 +++++++++ src/RefreshedCredentials.php | 107 +++++++++++++++ .../OAuth/BitbucketOauth2RefreshResolver.php | 25 ++++ .../OAuth/FacebookOauth2RefreshResolver.php | 25 ++++ .../OAuth/GithubOauth2RefreshResolver.php | 25 ++++ .../OAuth/GitlabOauth2RefreshResolver.php | 25 ++++ .../OAuth/GoogleOauth2RefreshResolver.php | 25 ++++ .../OAuth/LinkedInOauth2RefreshResolver.php | 25 ++++ .../OAuth/TwitterOauth2RefreshResolver.php | 25 ++++ src/Socialstream.php | 63 +++++++-- src/SocialstreamServiceProvider.php | 22 +++ .../Socialstream/UpdateConnectedAccount.php | 20 +++ stubs/app/Models/ConnectedAccount.php | 1 + stubs/app/Models/User.php | 4 +- stubs/app/Models/UserWithTeams.php | 4 +- tests/Feature/SocialstreamTest.php | 14 +- .../{ => Unit}/CreateUserFromProviderTest.php | 12 +- tests/Unit/CredentialsTest.php | 90 +++++++++++++ tests/{ => Unit}/ProvidersTest.php | 25 ++-- tests/Unit/RefreshedCredentialsTest.php | 110 +++++++++++++++ tests/Unit/RefreshesOauthTokensTest.php | 118 ++++++++++++++++ .../{ => Unit}/ResolveSocialiteUsersTest.php | 5 +- tests/{ => Unit}/SetPasswordTest.php | 5 +- 32 files changed, 948 insertions(+), 193 deletions(-) create mode 100644 src/Concerns/RefreshesOauth2Tokens.php create mode 100644 src/Contracts/Oauth2RefreshResolver.php create mode 100644 src/Contracts/RefreshedCredentials.php create mode 100644 src/HasOauth2Tokens.php create mode 100644 src/RefreshedCredentials.php create mode 100644 src/Resolvers/OAuth/BitbucketOauth2RefreshResolver.php create mode 100644 src/Resolvers/OAuth/FacebookOauth2RefreshResolver.php create mode 100644 src/Resolvers/OAuth/GithubOauth2RefreshResolver.php create mode 100644 src/Resolvers/OAuth/GitlabOauth2RefreshResolver.php create mode 100644 src/Resolvers/OAuth/GoogleOauth2RefreshResolver.php create mode 100644 src/Resolvers/OAuth/LinkedInOauth2RefreshResolver.php create mode 100644 src/Resolvers/OAuth/TwitterOauth2RefreshResolver.php rename tests/{ => Unit}/CreateUserFromProviderTest.php (89%) create mode 100644 tests/Unit/CredentialsTest.php rename tests/{ => Unit}/ProvidersTest.php (77%) create mode 100644 tests/Unit/RefreshedCredentialsTest.php create mode 100644 tests/Unit/RefreshesOauthTokensTest.php rename tests/{ => Unit}/ResolveSocialiteUsersTest.php (76%) rename tests/{ => Unit}/SetPasswordTest.php (84%) diff --git a/UPGRADE.md b/UPGRADE.md index 5ba59341..7dd44130 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -73,7 +73,6 @@ The function accepts a callback so if you wanted to implement more complex logic V3 introduces a new `Providers` class, for defining what Socialite providers you have enabled in your config. This class is also used in the socialstream.blade.php stub and the connected-account.blade.php component stub. Please update any Socialite providers you have in your `socialstream.php` config file to use this class, e.g: - ```php use \JoelButcher\Socialstream\Providers; diff --git a/config/socialstream.php b/config/socialstream.php index 82ebd52b..d053439d 100644 --- a/config/socialstream.php +++ b/config/socialstream.php @@ -49,5 +49,6 @@ // Features::generateMissingEmails(), Features::rememberSession(), Features::providerAvatars(), + Features::refreshOauthTokens(), ], ]; diff --git a/src/Concerns/RefreshesOauth2Tokens.php b/src/Concerns/RefreshesOauth2Tokens.php new file mode 100644 index 00000000..d5464d7f --- /dev/null +++ b/src/Concerns/RefreshesOauth2Tokens.php @@ -0,0 +1,66 @@ +refresh_token)) { + throw new \RuntimeException('A valid refresh token is required.'); + } + + $response = $this->getHttpClient()->post($this->getTokenUrl(), [ + RequestOptions::HEADERS => $this->getRefreshTokenHeaders(), + RequestOptions::FORM_PARAMS => $this->getRefreshTokenFields($connectedAccount->refresh_token), + ]); + + $response = json_decode($response->getBody(), true); + + return new RefreshedCredentials( + token: Arr::get($response, 'access_token'), + refreshToken: Arr::get($response, 'refresh_token'), + expiry: now()->addSeconds(Arr::get($response, 'expires_in')), + ); + } + + /** + * Get the headers for the refresh token request. + * + * @return array + */ + protected function getRefreshTokenHeaders(): array + { + return ['Accept' => 'application/json']; + } + + /** + * Get the POST fields for the refresh token request. + * + * @return array + */ + protected function getRefreshTokenFields(string $refreshToken): array + { + return [ + 'grant_type' => 'refresh_token', + 'client_id' => $this->clientId, + 'client_secret' => $this->clientSecret, + 'refresh_token' => $refreshToken, + ]; + } +} diff --git a/src/ConnectedAccount.php b/src/ConnectedAccount.php index 210cff0b..d332adff 100644 --- a/src/ConnectedAccount.php +++ b/src/ConnectedAccount.php @@ -2,7 +2,6 @@ namespace JoelButcher\Socialstream; -use DateTimeInterface; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Jetstream\Jetstream; @@ -10,14 +9,13 @@ /** * @property int $id * @property int $user_id + * @property string $provider * @property string $provider_id - * @property string $token - * @property string|null $secret - * @property string|null $refresh_token - * @property DateTimeInterface|null $expires_at */ abstract class ConnectedAccount extends Model { + use HasOauth2Tokens; + /** * Get the credentials used for authenticating services. */ diff --git a/src/Contracts/Credentials.php b/src/Contracts/Credentials.php index 4fda88ce..4136275c 100644 --- a/src/Contracts/Credentials.php +++ b/src/Contracts/Credentials.php @@ -4,7 +4,7 @@ use DateTimeInterface; -interface Credentials +interface Credentials extends RefreshedCredentials { /** * Get the ID for the credentials. @@ -12,32 +12,4 @@ interface Credentials * @return string */ public function getId(): string; - - /** - * Get token for the credentials. - * - * @return string - */ - public function getToken(): string; - - /** - * Get the token secret for the credentials. - * - * @return string|null - */ - public function getTokenSecret(): ?string; - - /** - * Get the refresh token for the credentials. - * - * @return string|null - */ - public function getRefreshToken(): ?string; - - /** - * Get the expiry date for the credentials. - * - * @return DateTimeInterface|null - */ - public function getExpiry(): ?DateTimeInterface; } diff --git a/src/Contracts/Oauth2RefreshResolver.php b/src/Contracts/Oauth2RefreshResolver.php new file mode 100644 index 00000000..0e59686b --- /dev/null +++ b/src/Contracts/Oauth2RefreshResolver.php @@ -0,0 +1,14 @@ +id = $connectedAccount->provider_id; - $this->token = $connectedAccount->token; - $this->tokenSecret = $connectedAccount->secret; - $this->refreshToken = $connectedAccount->refresh_token; - $this->expiry = $connectedAccount->expires_at; + + parent::__construct( + $connectedAccount->token, + $connectedAccount->secret, + $connectedAccount->refresh_token, + $connectedAccount->expires_at, + ); } /** * Get the ID for the credentials. - * - * @return string */ public function getId(): string { return $this->id; } - /** - * Get token for the credentials. - * - * @return string - */ - public function getToken(): string - { - return $this->token; - } - - /** - * Get the token secret for the credentials. - * - * @return string|null - */ - public function getTokenSecret(): ?string - { - return $this->tokenSecret; - } - - /** - * Get the refresh token for the credentials. - * - * @return string|null - */ - public function getRefreshToken(): ?string - { - return $this->refreshToken; - } - - /** - * Get the expiry date for the credentials. - * - * @return DateTimeInterface|null - * @throws Exception - */ - public function getExpiry(): ?DateTimeInterface - { - if (is_null($this->expiry)) { - return null; - } - - return new DateTime($this->expiry); - } - /** * Get the instance as an array. * @@ -119,40 +44,8 @@ public function getExpiry(): ?DateTimeInterface */ public function toArray(): array { - return [ + return array_merge([ 'id' => $this->getId(), - 'token' => $this->getToken(), - 'token_secret' => $this->getTokenSecret(), - 'refresh_token' => $this->getRefreshToken(), - 'expiry' => $this->getExpiry(), - ]; - } - - /** - * Convert the object to its JSON representation. - * - * @param int $options - */ - public function toJson($options = 0): string - { - return json_encode($this->toArray(), $options); - } - - /** - * Specify data which should be serialized to JSON. - * - * @return array - */ - public function jsonSerialize(): array - { - return $this->toArray(); - } - - /** - * Convert the object instance to a string. - */ - public function __toString(): string - { - return json_encode($this->toJson()); + ], parent::toArray()); } } diff --git a/src/Features.php b/src/Features.php index b34132a2..6253a474 100644 --- a/src/Features.php +++ b/src/Features.php @@ -55,6 +55,14 @@ public static function hasRememberSessionFeatures(): bool return static::enabled(static::rememberSession()); } + /** + * Determine if the application should refresh the tokens on retrieval. + */ + public static function refreshesOauthTokens(): bool + { + return static::enabled(static::refreshOauthTokens()); + } + /** * Enabled the generate missing emails feature. */ @@ -94,4 +102,12 @@ public static function rememberSession(): string { return 'remember-session'; } + + /** + * Enable the automatic refresh token update on token retrieval. + */ + public static function refreshOauthTokens(): string + { + return 'refresh-oauth-tokens'; + } } diff --git a/src/HasOauth2Tokens.php b/src/HasOauth2Tokens.php new file mode 100644 index 00000000..eacc1c8b --- /dev/null +++ b/src/HasOauth2Tokens.php @@ -0,0 +1,67 @@ +canRefreshToken()) { + app(UpdateConnectedAccount::class)->updateRefreshToken($this); + + return $this->getAttribute('token'); + } + + return $token; + }, + ); + } + + /** + * Determines if the "token" attribute can be refreshed. + */ + public function canRefreshToken(): bool + { + return $this->hasExpiredToken() && $this->hasRefreshToken(); + } + + /** + * Determines if the token has expired. + */ + public function hasExpiredToken(): bool + { + return $this->expires_at && Carbon::parse($this->expires_at)->lte(now()); + } + + /** + * Determines if the model has a valid token. + */ + public function hasRefreshToken(): bool + { + return ! is_null($this->refresh_token); + } +} diff --git a/src/RefreshedCredentials.php b/src/RefreshedCredentials.php new file mode 100644 index 00000000..81916b13 --- /dev/null +++ b/src/RefreshedCredentials.php @@ -0,0 +1,107 @@ +token; + } + + /** + * Get the token secret for the credentials. + */ + public function getTokenSecret(): ?string + { + return $this->tokenSecret; + } + + /** + * Get the refresh token for the credentials. + */ + public function getRefreshToken(): ?string + { + return $this->refreshToken; + } + + /** + * Get the expiry date for the credentials. + */ + public function getExpiry(): ?DateTimeInterface + { + if (is_null($this->expiry)) { + return null; + } + + if ($this->expiry instanceof DateTimeInterface) { + return $this->expiry; + } + + return new DateTime($this->expiry); + } + + /** + * Get the instance as an array. + * + * @return array + */ + public function toArray(): array + { + return [ + 'token' => $this->getToken(), + 'token_secret' => $this->getTokenSecret(), + 'refresh_token' => $this->getRefreshToken(), + 'expiry' => $this->getExpiry(), + ]; + } + + /** + * Convert the object to its JSON representation. + */ + public function toJson($options = 0): string + { + return json_encode($this, $options); + } + + /** + * Specify data which should be serialized to JSON. + * + * @return array + */ + public function jsonSerialize(): array + { + return $this->toArray(); + } + + /** + * Convert the object instance to a string. + * + * @return string + */ + public function __toString() + { + return json_encode($this); + } +} diff --git a/src/Resolvers/OAuth/BitbucketOauth2RefreshResolver.php b/src/Resolvers/OAuth/BitbucketOauth2RefreshResolver.php new file mode 100644 index 00000000..268dd566 --- /dev/null +++ b/src/Resolvers/OAuth/BitbucketOauth2RefreshResolver.php @@ -0,0 +1,25 @@ + + */ + public static array $refreshTokenResolvers = []; + /** * Determine whether Socialstream is enabled in the application. */ @@ -69,8 +74,6 @@ public static function providers(): array /** * Determine if the application has support for the Bitbucket provider.. - * - * @return bool */ public static function hasBitbucketSupport(): bool { @@ -79,8 +82,6 @@ public static function hasBitbucketSupport(): bool /** * Determine if the application has support for the Facebook provider.. - * - * @return bool */ public static function hasFacebookSupport(): bool { @@ -175,6 +176,14 @@ public static function hasRememberSessionFeatures(): bool return Features::hasRememberSessionFeatures(); } + /** + * Determine if the application should refresh the tokens on retrieval. + */ + public static function refresesOauthTokens(): bool + { + return Features::refreshesOauthTokens(); + } + /** * Find a connected account instance fot a given provider and provider ID. */ @@ -277,4 +286,32 @@ public static function generatesProvidersRedirectsUsing(callable|string $callbac { app()->singleton(GeneratesProviderRedirect::class, $callback); } + + /** + * Register a class / callback that should be used for refreshing tokens for the given OAuth 2.0 provider. + */ + public static function refreshesTokensForProviderUsing(string $provider, callable|string $callback): void + { + static::$refreshTokenResolvers[Str::lower($provider)] = $callback; + } + + /** + * Refresh the given connected account token. + */ + public static function refreshConnectedAccountToken(ConnectedAccount $connectedAccount): RefreshedCredentials + { + $provider = Str::lower($connectedAccount->provider); + + $callback = static::$refreshTokenResolvers[$provider]; + + if (! $callback) { + throw new RuntimeException("Failed to refresh token. Could not find the associated resolver for the '$provider' provider."); + } + + if (is_callable($callback)) { + return $callback($connectedAccount); + } + + return (new $callback)->refreshToken($connectedAccount); + } } diff --git a/src/SocialstreamServiceProvider.php b/src/SocialstreamServiceProvider.php index 90f3f08f..49d953ee 100644 --- a/src/SocialstreamServiceProvider.php +++ b/src/SocialstreamServiceProvider.php @@ -11,6 +11,13 @@ use JoelButcher\Socialstream\Http\Livewire\ConnectedAccountsForm; use JoelButcher\Socialstream\Http\Livewire\SetPasswordForm; use JoelButcher\Socialstream\Http\Middleware\ShareInertiaData; +use JoelButcher\Socialstream\Resolvers\OAuth\BitbucketOauth2RefreshResolver; +use JoelButcher\Socialstream\Resolvers\OAuth\FacebookOauth2RefreshResolver; +use JoelButcher\Socialstream\Resolvers\OAuth\GithubOauth2RefreshResolver; +use JoelButcher\Socialstream\Resolvers\OAuth\GitlabOauth2RefreshResolver; +use JoelButcher\Socialstream\Resolvers\OAuth\GoogleOauth2RefreshResolver; +use JoelButcher\Socialstream\Resolvers\OAuth\LinkedInOauth2RefreshResolver; +use JoelButcher\Socialstream\Resolvers\OAuth\TwitterOauth2RefreshResolver; use Livewire\Livewire; class SocialstreamServiceProvider extends ServiceProvider @@ -38,6 +45,7 @@ public function boot(): void $this->configurePublishing(); $this->configureRoutes(); $this->configureCommands(); + $this->configureRefreshTokenResolvers(); if (config('jetstream.stack') === 'inertia') { $this->bootInertia(); @@ -100,6 +108,20 @@ protected function configureCommands(): void ]); } + /** + * Configure the refresh token resolvers as defaults. + */ + protected function configureRefreshTokenResolvers(): void + { + Socialstream::refreshesTokensForProviderUsing(Providers::google(), GoogleOauth2RefreshResolver::class); + Socialstream::refreshesTokensForProviderUsing(Providers::facebook(), FacebookOauth2RefreshResolver::class); + Socialstream::refreshesTokensForProviderUsing(Providers::linkedin(), LinkedInOauth2RefreshResolver::class); + Socialstream::refreshesTokensForProviderUsing(Providers::bitbucket(), BitbucketOauth2RefreshResolver::class); + Socialstream::refreshesTokensForProviderUsing(Providers::github(), GithubOauth2RefreshResolver::class); + Socialstream::refreshesTokensForProviderUsing(Providers::gitlab(), GitlabOauth2RefreshResolver::class); + Socialstream::refreshesTokensForProviderUsing(Providers::twitter(), TwitterOauth2RefreshResolver::class); + } + /** * Boot any Inertia related services. */ diff --git a/stubs/app/Actions/Socialstream/UpdateConnectedAccount.php b/stubs/app/Actions/Socialstream/UpdateConnectedAccount.php index d929de57..3aefaec1 100644 --- a/stubs/app/Actions/Socialstream/UpdateConnectedAccount.php +++ b/stubs/app/Actions/Socialstream/UpdateConnectedAccount.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Gate; use JoelButcher\Socialstream\ConnectedAccount; use JoelButcher\Socialstream\Contracts\UpdatesConnectedAccounts; +use JoelButcher\Socialstream\Socialstream; use Laravel\Socialite\Contracts\User; class UpdateConnectedAccount implements UpdatesConnectedAccounts @@ -31,4 +32,23 @@ public function update(mixed $user, ConnectedAccount $connectedAccount, string $ return $connectedAccount; } + + /** + * Update the refresh token for the given account. + */ + public function updateRefreshToken(ConnectedAccount $connectedAccount): ConnectedAccount + { + $refreshedCredentials = Socialstream::refreshConnectedAccountToken( + $connectedAccount, + ); + + $connectedAccount->forceFill([ + 'token' => $refreshedCredentials->getToken(), + 'secret' => $refreshedCredentials->getTokenSecret(), + 'refresh_token' => $refreshedCredentials->getRefreshToken(), + 'expires_at' => $refreshedCredentials->getExpiry(), + ])->save(); + + return $connectedAccount; + } } diff --git a/stubs/app/Models/ConnectedAccount.php b/stubs/app/Models/ConnectedAccount.php index 88a3926d..295281d8 100644 --- a/stubs/app/Models/ConnectedAccount.php +++ b/stubs/app/Models/ConnectedAccount.php @@ -27,6 +27,7 @@ class ConnectedAccount extends SocialstreamConnectedAccount 'email', 'avatar_path', 'token', + 'secret', 'refresh_token', 'expires_at', ]; diff --git a/stubs/app/Models/User.php b/stubs/app/Models/User.php index 5593d3ed..cdadf969 100644 --- a/stubs/app/Models/User.php +++ b/stubs/app/Models/User.php @@ -30,7 +30,9 @@ class User extends Authenticatable * @var array */ protected $fillable = [ - 'name', 'email', 'password', + 'name', + 'email', + 'password', ]; /** diff --git a/stubs/app/Models/UserWithTeams.php b/stubs/app/Models/UserWithTeams.php index f316b0d3..27589171 100644 --- a/stubs/app/Models/UserWithTeams.php +++ b/stubs/app/Models/UserWithTeams.php @@ -32,7 +32,9 @@ class User extends Authenticatable * @var array */ protected $fillable = [ - 'name', 'email', 'password', + 'name', + 'email', + 'password', ]; /** diff --git a/tests/Feature/SocialstreamTest.php b/tests/Feature/SocialstreamTest.php index 82dbf913..ad1ba693 100644 --- a/tests/Feature/SocialstreamTest.php +++ b/tests/Feature/SocialstreamTest.php @@ -59,7 +59,7 @@ protected function getPackageProviders($app): array ]); } - public function test_redirect() + public function test_redirect(): void { $response = $this->get(route('oauth.redirect', 'github')); @@ -67,7 +67,7 @@ public function test_redirect() ->assertRedirectContains('github.com'); } - public function test_users_can_register() + public function test_users_can_register(): void { $user = (new SocialiteUser()) ->map([ @@ -101,7 +101,7 @@ public function test_users_can_register() ]); } - public function test_existing_users_can_login() + public function test_existing_users_can_login(): void { $user = User::create([ 'name' => 'Joel Butcher', @@ -147,7 +147,7 @@ public function test_existing_users_can_login() $this->assertAuthenticated(); } - public function test_authenticated_users_can_link_to_provider() + public function test_authenticated_users_can_link_to_provider(): void { $this->actingAs(User::create([ 'name' => 'Joel Butcher', @@ -187,7 +187,7 @@ public function test_authenticated_users_can_link_to_provider() ]); } - public function test_new_users_can_register_from_login_page() + public function test_new_users_can_register_from_login_page(): void { Config::set('socialstream.features', [ Features::createAccountOnFirstLogin(), @@ -225,7 +225,7 @@ public function test_new_users_can_register_from_login_page() ]); } - public function test_users_can_login_on_registration() + public function test_users_can_login_on_registration(): void { Config::set('socialstream.features', [ Features::loginOnRegistration(), @@ -271,7 +271,7 @@ public function test_users_can_login_on_registration() ]); } - public function test_it_generates_missing_emails() + public function test_it_generates_missing_emails(): void { Config::set('socialstream.features', [ Features::generateMissingEmails(), diff --git a/tests/CreateUserFromProviderTest.php b/tests/Unit/CreateUserFromProviderTest.php similarity index 89% rename from tests/CreateUserFromProviderTest.php rename to tests/Unit/CreateUserFromProviderTest.php index 37943223..4d9c591f 100644 --- a/tests/CreateUserFromProviderTest.php +++ b/tests/Unit/CreateUserFromProviderTest.php @@ -1,6 +1,6 @@ migrate(); @@ -41,7 +42,7 @@ public function test_user_can_be_created_from_o_auth_1_provider() $this->assertNull($credentials->getExpiry()); } - public function test_user_can_be_created_from_o_auth_2_provider() + public function test_user_can_be_created_from_o_auth_2_provider(): void { $this->migrate(); @@ -69,4 +70,9 @@ public function test_user_can_be_created_from_o_auth_2_provider() $this->assertNull($credentials->getTokenSecret()); $this->assertInstanceOf(DateTimeInterface::class, $credentials->getExpiry()); } + + protected function migrate() + { + $this->artisan('migrate', ['--database' => 'testbench'])->run(); + } } diff --git a/tests/Unit/CredentialsTest.php b/tests/Unit/CredentialsTest.php new file mode 100644 index 00000000..eaaed4b2 --- /dev/null +++ b/tests/Unit/CredentialsTest.php @@ -0,0 +1,90 @@ + 'Joel Butcher', + 'email' => 'joel@socialstream.dev', + ])->connectedAccounts()->create([ + 'provider' => 'github', + 'provider_id' => 12345678, + 'token' => 'some-token', + 'secret' => 'some-token-secret', + 'refresh_token' => 'some-refresh-token', + 'expires_at' => Carbon::now()->addHour(), + ]) + ); + + $this->assertEquals('12345678', $credentials->getId()); + $this->assertEquals('some-token', $credentials->getToken()); + $this->assertEquals('some-token-secret', $credentials->getTokenSecret()); + $this->assertEquals('some-refresh-token', $credentials->getRefreshToken()); + $this->assertEquals('2023-04-13 01:00:00', $credentials->getExpiry()); + } + + public function test_it_can_be_cast_to_an_array() + { + Carbon::setTestNow(DateTime::createFromFormat('Y-m-d H:i:s', '2023-04-13 00:00:00')); + + $credentials = new Credentials( + User::create([ + 'name' => 'Joel Butcher', + 'email' => 'joel@socialstream.dev', + ])->connectedAccounts()->create([ + 'provider' => 'github', + 'provider_id' => 12345678, + 'token' => 'some-token', + 'secret' => 'some-token-secret', + 'refresh_token' => 'some-refresh-token', + 'expires_at' => Carbon::now()->addHour(), + ]) + ); + + $this->assertEquals([ + 'id' => '12345678', + 'token' => 'some-token', + 'token_secret' => 'some-token-secret', + 'refresh_token' => 'some-refresh-token', + 'expiry' => Carbon::createFromFormat('Y-m-d H:i:s', '2023-04-13 01:00:00'), + ], $credentials->toArray()); + } + + public function test_it_can_be_json_encoded() + { + Carbon::setTestNow(DateTime::createFromFormat('Y-m-d H:i:s', '2023-04-13 00:00:00')); + + $credentials = new Credentials( + User::create([ + 'name' => 'Joel Butcher', + 'email' => 'joel@socialstream.dev', + ])->connectedAccounts()->create([ + 'provider' => 'github', + 'provider_id' => 12345678, + 'token' => 'some-token', + 'secret' => 'some-token-secret', + 'refresh_token' => 'some-refresh-token', + 'expires_at' => Carbon::now()->addHour(), + ]) + ); + + $expected = '{"id":"12345678","token":"some-token","token_secret":"some-token-secret","refresh_token":"some-refresh-token","expiry":"2023-04-13T01:00:00.000000Z"}'; + + $this->assertEquals($expected, json_encode($credentials)); + } +} diff --git a/tests/ProvidersTest.php b/tests/Unit/ProvidersTest.php similarity index 77% rename from tests/ProvidersTest.php rename to tests/Unit/ProvidersTest.php index b153f64e..604c48c2 100644 --- a/tests/ProvidersTest.php +++ b/tests/Unit/ProvidersTest.php @@ -1,83 +1,84 @@ assertTrue(Providers::hasBitbucketSupport()); } - public function test_it_supports_facebook_provider() + public function test_it_supports_facebook_provider(): void { Config::set('socialstream.providers', [Providers::facebook()]); $this->assertTrue(Providers::hasFacebookSupport()); } - public function test_it_supports_github_provider() + public function test_it_supports_github_provider(): void { Config::set('socialstream.providers', [Providers::github()]); $this->assertTrue(Providers::hasGithubSupport()); } - public function test_it_supports_gitlab_provider() + public function test_it_supports_gitlab_provider(): void { Config::set('socialstream.providers', [Providers::gitlab()]); $this->assertTrue(Providers::hasGitlabSupport()); } - public function test_it_supports_google_provider() + public function test_it_supports_google_provider(): void { Config::set('socialstream.providers', [Providers::google()]); $this->assertTrue(Providers::hasGoogleSupport()); } - public function test_it_supports_linked_in_provider() + public function test_it_supports_linked_in_provider(): void { Config::set('socialstream.providers', [Providers::linkedin()]); $this->assertTrue(Providers::hasLinkedInSupport()); } - public function test_it_supports_twitter_provider() + public function test_it_supports_twitter_provider(): void { Config::set('socialstream.providers', [Providers::twitter()]); $this->assertTrue(Providers::hasTwitterSupport()); } - public function test_it_supports_twitter_o_auth_1_provider() + public function test_it_supports_twitter_o_auth_1_provider(): void { Config::set('socialstream.providers', [Providers::twitterOAuth1()]); $this->assertTrue(Providers::hasTwitterOAuth1Support()); } - public function test_it_supports_twitter_o_auth_2_provider() + public function test_it_supports_twitter_o_auth_2_provider(): void { Config::set('socialstream.providers', [Providers::twitterOAuth2()]); $this->assertTrue(Providers::hasTwitterOAuth2Support()); } - public function test_it_supports_custom_providers() + public function test_it_supports_custom_providers(): void { Config::set('socialstream.providers', ['my-custom-provider']); $this->assertTrue(Providers::enabled('my-custom-provider')); } - public function test_it_supports_dynamic_calls_for_custom_providers() + public function test_it_supports_dynamic_calls_for_custom_providers(): void { Config::set('socialstream.providers', ['a-custom-provider', 'another-provider', 'and-another']); diff --git a/tests/Unit/RefreshedCredentialsTest.php b/tests/Unit/RefreshedCredentialsTest.php new file mode 100644 index 00000000..410c364c --- /dev/null +++ b/tests/Unit/RefreshedCredentialsTest.php @@ -0,0 +1,110 @@ +assertEquals('token', $credentials->getToken()); + + $this->expectException(ArgumentCountError::class); + new RefreshedCredentials(); + } + + public function test_token_secret_can_be_nullable(): void + { + $credentials = new RefreshedCredentials( + 'token', + 'token-secret', + ); + + $this->assertEquals('token-secret', $credentials->getTokenSecret()); + + $credentials = new RefreshedCredentials( + 'token', + ); + + $this->assertNull($credentials->getTokenSecret()); + } + + public function test_refresh_token_can_be_nullable(): void + { + $credentials = new RefreshedCredentials( + 'token', + 'token-secret', + 'refresh-token' + ); + + $this->assertEquals('refresh-token', $credentials->getRefreshToken()); + + $credentials = new RefreshedCredentials( + 'token', + ); + + $this->assertNull($credentials->getRefreshToken()); + } + + public function test_expiry_can_be_nullable(): void + { + $credentials = new RefreshedCredentials( + 'some-token', + 'some-token-secret', + 'some-refresh-token', + Carbon::now()->addHour(), + ); + + $this->assertEquals( + Carbon::now()->addHour()->format('Y-m-d H:i:s'), + $credentials->getExpiry()->format('Y-m-d H:i:s') + ); + + $credentials = new RefreshedCredentials( + 'token', + ); + + $this->assertNull($credentials->getExpiry()); + } + + public function test_it_can_be_cast_to_an_array() + { + Carbon::setTestNow(DateTime::createFromFormat('Y-m-d H:i:s', '2023-04-13 00:00:00')); + + $credentials = new RefreshedCredentials( + 'some-token', + 'some-token-secret', + 'some-refresh-token', + Carbon::now()->addHour(), + ); + + $this->assertEquals([ + 'token' => 'some-token', + 'token_secret' => 'some-token-secret', + 'refresh_token' => 'some-refresh-token', + 'expiry' => Carbon::createFromFormat('Y-m-d H:i:s', '2023-04-13 01:00:00'), + ], $credentials->toArray()); + } + + public function test_it_can_be_json_encoded() + { + Carbon::setTestNow(DateTime::createFromFormat('Y-m-d H:i:s', '2023-04-13 00:00:00')); + + $credentials = new RefreshedCredentials( + 'some-token', + 'some-token-secret', + 'some-refresh-token', + Carbon::now()->addHour(), + ); + + $expected = '{"token":"some-token","token_secret":"some-token-secret","refresh_token":"some-refresh-token","expiry":"2023-04-13T01:00:00.000000Z"}'; + + $this->assertEquals($expected, json_encode($credentials)); + } +} diff --git a/tests/Unit/RefreshesOauthTokensTest.php b/tests/Unit/RefreshesOauthTokensTest.php new file mode 100644 index 00000000..6847421f --- /dev/null +++ b/tests/Unit/RefreshesOauthTokensTest.php @@ -0,0 +1,118 @@ +migrate(); + + Socialstream::refreshesTokensForProviderUsing('github', function () { + return new RefreshedCredentials( + 'new-token', + null, + 'new-refresh-token', + now()->addSeconds(3600), + ); + }); + + $providerUser = new OAuth2User; + $providerUser->id = '1234567890'; + $providerUser->name = 'Joel Butcher'; + $providerUser->email = 'joel@socialstream.com'; + $providerUser->token = Str::random(64); + $providerUser->refreshToken = Str::random(64); + $providerUser->expiresIn = 0; + + sleep(1); + + $createAction = new CreateUserFromProvider(new CreateConnectedAccount); + $user = $createAction->create('github', $providerUser); + $connectedAccount = $user->currentConnectedAccount; + + $this->assertEquals('new-token', $connectedAccount->token); + $this->assertEquals('new-refresh-token', $connectedAccount->refresh_token); + $this->assertEquals(null, $connectedAccount->secret); + } + + public function test_it_does_not_refresh_active_tokens(): void + { + $this->migrate(); + + Socialstream::refreshesTokensForProviderUsing('github', function () { + return new RefreshedCredentials( + 'new-token', + null, + 'new-refresh-token', + now()->addSeconds(3600), + ); + }); + + $providerUser = new OAuth2User; + $providerUser->id = '1234567890'; + $providerUser->name = 'Joel Butcher'; + $providerUser->email = 'joel@socialstream.com'; + $providerUser->token = Str::random(64); + $providerUser->refreshToken = Str::random(64); + $providerUser->expiresIn = 3600; + + sleep(1); + + $createAction = new CreateUserFromProvider(new CreateConnectedAccount); + $user = $createAction->create('github', $providerUser); + + /** @var ConnectedAccount $connectedAccount */ + $connectedAccount = $user->currentConnectedAccount; + + $this->assertNotEquals('new-token', $connectedAccount->token); + $this->assertNotEquals('new-refresh-token', $connectedAccount->refresh_token); + $this->assertEquals(null, $connectedAccount->secret); + } + + public function test_it_does_not_refresh_tokens_if_the_feature_is_disabled(): void + { + $this->migrate(); + + Config::set('socialstream.features', []); + + $newTime = now()->addSeconds(3600); + + Socialstream::refreshesTokensForProviderUsing('github', function (ConnectedAccount $account) use ($newTime) { + return new RefreshedCredentials( + 'new-token', + null, + 'new-refresh-token', + $newTime, + ); + }); + + $providerUser = new OAuth2User; + $providerUser->id = '1234567890'; + $providerUser->name = 'Joel Butcher'; + $providerUser->email = 'joel@socialstream.com'; + $providerUser->token = Str::random(64); + $providerUser->refreshToken = Str::random(64); + $providerUser->expiresIn = 0; + + sleep(1); + + $createAction = new CreateUserFromProvider(new CreateConnectedAccount); + $user = $createAction->create('github', $providerUser); + $connectedAccount = $user->currentConnectedAccount; + + $this->assertNotEquals('new-token', $connectedAccount->token); + $this->assertNotEquals('new-refresh-token', $connectedAccount->refresh_token); + $this->assertEquals(null, $connectedAccount->secret); + } +} diff --git a/tests/ResolveSocialiteUsersTest.php b/tests/Unit/ResolveSocialiteUsersTest.php similarity index 76% rename from tests/ResolveSocialiteUsersTest.php rename to tests/Unit/ResolveSocialiteUsersTest.php index cbd43c3d..63596499 100644 --- a/tests/ResolveSocialiteUsersTest.php +++ b/tests/Unit/ResolveSocialiteUsersTest.php @@ -1,10 +1,11 @@ migrate();