From 08ca13af898a59b883226a223842a5dff1f67b1f Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:22:10 +0300 Subject: [PATCH 1/2] feat: configurable TikTok OAuth scopes via TIKTOK_SCOPES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TikTok no longer offers the Display API product to new apps, which owns user.info.profile, user.info.stats and video.list — a stock app can only enable Login Kit + Content Posting API, and the hardcoded six-scope request then fails on the consent screen with a bare "scope" error. Scopes now come from config (default unchanged) so self-hosters can trim the request to what their app actually has; analytics degrade gracefully and the username simply stays empty without the profile scope. --- .../Controllers/Auth/TikTokController.php | 22 ++++++++++--------- config/trypost.php | 4 ++++ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Auth/TikTokController.php b/app/Http/Controllers/Auth/TikTokController.php index 8e39b59ac..f47edfae9 100644 --- a/app/Http/Controllers/Auth/TikTokController.php +++ b/app/Http/Controllers/Auth/TikTokController.php @@ -20,14 +20,16 @@ class TikTokController extends SocialController protected SocialPlatform $platform = SocialPlatform::TikTok; - protected array $scopes = [ - 'user.info.basic', - 'user.info.profile', - 'user.info.stats', - 'video.publish', - 'video.upload', - 'video.list', - ]; + /** + * Requested scopes come from config so self-hosters whose TikTok app lacks + * a product (Display API is no longer offered to new apps, taking + * user.info.profile/stats and video.list with it) can trim the list via + * TIKTOK_SCOPES instead of hitting a "scope" error on the consent screen. + */ + private function scopes(): array + { + return (array) config('trypost.platforms.tiktok.scopes'); + } public function connect(Request $request): Response { @@ -37,7 +39,7 @@ public function connect(Request $request): Response $this->authorize('manageAccounts', $workspace); - return $this->redirectToProvider($request, $this->driver, $this->scopes, [ + return $this->redirectToProvider($request, $this->driver, $this->scopes(), [ 'disable_auto_auth' => 1, ]); } @@ -48,7 +50,7 @@ public function callback(Request $request): InertiaResponse try { $socialUser = Socialite::driver($this->driver) - ->scopes($this->scopes) + ->scopes($this->scopes()) ->user(); // TikTok returns username via getNickname() when user.info.profile scope is included diff --git a/config/trypost.php b/config/trypost.php index aed84cd6b..8288024d3 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -191,6 +191,10 @@ 'tiktok' => [ 'enabled' => env('TIKTOK_ENABLED', true), 'api' => env('TIKTOK_API', 'https://open.tiktokapis.com/v2'), + // OAuth scopes to request. Trim when the TikTok app lacks a product + // (e.g. no Display API => drop user.info.profile, user.info.stats, + // video.list; analytics degrade gracefully, username stays empty). + 'scopes' => array_values(array_filter(array_map('trim', explode(',', (string) env('TIKTOK_SCOPES', 'user.info.basic,user.info.profile,user.info.stats,video.publish,video.upload,video.list'))))), ], 'youtube' => [ 'enabled' => env('YOUTUBE_ENABLED', true), From 6332045e36cb4bef3b30fdd66693c9e2b009bd9c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 21 Sep 2026 14:28:59 -0300 Subject: [PATCH 2/2] Document TIKTOK_SCOPES and lock the config path with tests. Self-hosters with a 2026 TikTok app never see the knob without the env example, and without a test the hardcoded scopes can sneak back. --- .env.example | 2 + app/Models/SocialAccount.php | 7 +-- docker/.env.docker.example | 1 + tests/Feature/Social/TikTokControllerTest.php | 53 +++++++++++++++++++ 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index b7812c65b..4e9468566 100644 --- a/.env.example +++ b/.env.example @@ -134,6 +134,8 @@ X_CLIENT_REDIRECT="${APP_URL}/accounts/x/callback" TIKTOK_CLIENT_ID= TIKTOK_CLIENT_SECRET= TIKTOK_CLIENT_REDIRECT="${APP_URL}/accounts/tiktok/callback" +# Trim when the TikTok app lacks Display API (no longer offered to new apps): +# TIKTOK_SCOPES="user.info.basic,video.publish,video.upload" # Facebook (https://developers.facebook.com) FACEBOOK_CLIENT_ID= diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index 3e86b581e..804836ff2 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -289,9 +289,10 @@ protected function profileUrl(): Attribute /** * "@handle" for notification bodies — the more specific identifier * (username) wins over the friendlier display name when both are set. - * Every connector requests enough scope to always populate at least one - * of username/display_name (e.g. TikTok always requests user.info.profile); - * the platform label is a last-resort fallback, not an expected path. + * Connectors normally populate at least one of username/display_name + * (TikTok Login Kit still returns display_name via user.info.basic; + * username needs user.info.profile, which self-hosters may trim). + * The platform label is a last-resort fallback, not an expected path. */ public function handle(): string { diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 4f214308e..f8f09324d 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -106,6 +106,7 @@ X_CLIENT_REDIRECT="${APP_URL}/accounts/x/callback" TIKTOK_CLIENT_ID= TIKTOK_CLIENT_SECRET= TIKTOK_CLIENT_REDIRECT="${APP_URL}/accounts/tiktok/callback" +# TIKTOK_SCOPES="user.info.basic,video.publish,video.upload" FACEBOOK_CLIENT_ID= FACEBOOK_CLIENT_SECRET= diff --git a/tests/Feature/Social/TikTokControllerTest.php b/tests/Feature/Social/TikTokControllerTest.php index 19b9c0265..77e7ba6a1 100644 --- a/tests/Feature/Social/TikTokControllerTest.php +++ b/tests/Feature/Social/TikTokControllerTest.php @@ -27,6 +27,59 @@ ->toContain('disable_auto_auth=1'); }); +test('tiktok authorize url carries the default scopes', function () { + $response = $this->actingAs($this->user)->get(route('app.social.tiktok.connect')); + + expect(urldecode((string) $response->headers->get('Location'))) + ->toStartWith('https://www.tiktok.com/v2/auth/authorize') + ->toContain('user.info.basic') + ->toContain('user.info.profile') + ->toContain('user.info.stats') + ->toContain('video.publish') + ->toContain('video.upload') + ->toContain('video.list'); +}); + +/** + * Mock the TikTok driver, hit connect, and return the scopes the controller asked for. + * + * @return array + */ +function captureTikTokConnectScopes(object $test): array +{ + $captured = []; + + $driverMock = Mockery::mock(); + $driverMock->shouldReceive('scopes') + ->withArgs(function (array $scopes) use (&$captured) { + $captured = $scopes; + + return true; + }) + ->andReturnSelf(); + $driverMock->shouldReceive('with')->with(['disable_auto_auth' => 1])->andReturnSelf(); + $driverMock->shouldReceive('redirect')->andReturn(Mockery::mock([ + 'getTargetUrl' => 'https://www.tiktok.com/v2/auth/authorize?test=1', + ])); + + Socialite::shouldReceive('driver')->with('tiktok')->andReturn($driverMock); + + $test->actingAs($test->user) + ->get(route('app.social.tiktok.connect')); + + return $captured; +} + +test('tiktok connect requests scopes from config', function () { + config(['trypost.platforms.tiktok.scopes' => ['user.info.basic', 'video.publish', 'video.upload']]); + + expect(captureTikTokConnectScopes($this))->toEqual([ + 'user.info.basic', + 'video.publish', + 'video.upload', + ]); +}); + test('tiktok connect redirects to oauth provider', function () { $driverMock = Mockery::mock(); $driverMock->shouldReceive('scopes')->andReturnSelf();