From 16309b1420f2e01eb64db1b15259b33e1a4ca262 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 11 Apr 2026 13:44:57 +0100 Subject: [PATCH 1/5] Migrate from GitHub OAuth App to GitHub App Replace the broad-scope OAuth App integration with a fine-grained GitHub App that uses user access tokens for login and installation access tokens for repo operations. Legacy OAuth users see a blocking banner encouraging them to upgrade, and plugin submission is gated until they do. - Add github_installations table and github_auth_type column on users - Register github-app Socialite driver alongside legacy github driver - Add GitHubAppService for JWT generation and installation token management - Add webhook controller for installation lifecycle events - Update token resolution with 3-tier priority: installation > user > platform - Add migration banner to integrations, plugins index, and plugin create pages - Add GitHubAppStatus Livewire component showing installation coverage - Block legacy OAuth users from plugin submission - Add 27 new tests covering auth, webhooks, token resolution, and UI Co-Authored-By: Claude Opus 4.6 --- .env.example | 11 + app/Enums/GitHubAuthType.php | 17 ++ .../Controllers/CustomerPluginController.php | 6 + .../GitHubAppWebhookController.php | 174 ++++++++++++++ app/Http/Controllers/GitHubAuthController.php | 18 +- .../GitHubIntegrationController.php | 120 +++++++++- app/Http/Middleware/VerifyCsrfToken.php | 1 + app/Jobs/Concerns/ResolvesGitHubToken.php | 24 ++ app/Livewire/GitHubAppStatus.php | 43 ++++ app/Models/GitHubInstallation.php | 83 +++++++ app/Models/User.php | 25 ++ app/Providers/AppServiceProvider.php | 22 ++ app/Services/GitHubAppService.php | 91 ++++++++ app/Services/GitHubUserService.php | 92 +++++++- app/Services/PluginSyncService.php | 17 ++ config/services.php | 10 + .../factories/GitHubInstallationFactory.php | 52 +++++ database/factories/UserFactory.php | 20 ++ ...2642_create_github_installations_table.php | 39 ++++ ...47_add_github_auth_type_to_users_table.php | 34 +++ .../github-migration-banner.blade.php | 44 ++++ .../views/customer/integrations.blade.php | 10 + .../views/customer/plugins/create.blade.php | 5 + .../views/customer/plugins/index.blade.php | 3 + .../livewire/git-hub-app-status.blade.php | 95 ++++++++ routes/web.php | 4 + tests/Feature/GitHubAppAuthTest.php | 160 +++++++++++++ tests/Feature/GitHubAppWebhookTest.php | 218 ++++++++++++++++++ tests/Feature/GitHubMigrationBannerTest.php | 100 ++++++++ tests/Feature/GitHubTokenResolutionTest.php | 86 +++++++ tests/Unit/GitHubAppServiceTest.php | 92 ++++++++ 31 files changed, 1703 insertions(+), 13 deletions(-) create mode 100644 app/Enums/GitHubAuthType.php create mode 100644 app/Http/Controllers/GitHubAppWebhookController.php create mode 100644 app/Livewire/GitHubAppStatus.php create mode 100644 app/Models/GitHubInstallation.php create mode 100644 app/Services/GitHubAppService.php create mode 100644 database/factories/GitHubInstallationFactory.php create mode 100644 database/migrations/2026_03_23_122642_create_github_installations_table.php create mode 100644 database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php create mode 100644 resources/views/components/github-migration-banner.blade.php create mode 100644 resources/views/livewire/git-hub-app-status.blade.php create mode 100644 tests/Feature/GitHubAppAuthTest.php create mode 100644 tests/Feature/GitHubAppWebhookTest.php create mode 100644 tests/Feature/GitHubMigrationBannerTest.php create mode 100644 tests/Feature/GitHubTokenResolutionTest.php create mode 100644 tests/Unit/GitHubAppServiceTest.php diff --git a/.env.example b/.env.example index a82d36de6..b243d7f01 100644 --- a/.env.example +++ b/.env.example @@ -87,3 +87,14 @@ ANYSTACK_TRIAL_POLICY_ID= FILAMENT_USERS= BIFROST_API_KEY=your-secure-api-key-here + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_TOKEN= + +GITHUB_APP_ID= +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +GITHUB_APP_PRIVATE_KEY_PATH= +GITHUB_APP_WEBHOOK_SECRET= +GITHUB_APP_SLUG= diff --git a/app/Enums/GitHubAuthType.php b/app/Enums/GitHubAuthType.php new file mode 100644 index 000000000..555671d72 --- /dev/null +++ b/app/Enums/GitHubAuthType.php @@ -0,0 +1,17 @@ + 'OAuth App', + self::App => 'GitHub App', + }; + } +} diff --git a/app/Http/Controllers/CustomerPluginController.php b/app/Http/Controllers/CustomerPluginController.php index 29fa1e23c..cc1def512 100644 --- a/app/Http/Controllers/CustomerPluginController.php +++ b/app/Http/Controllers/CustomerPluginController.php @@ -49,6 +49,12 @@ public function store(SubmitPluginRequest $request, PluginSyncService $syncServi { $user = Auth::user(); + // Block legacy OAuth users from submitting plugins + if ($user->needsGitHubAppMigration()) { + return to_route('customer.integrations') + ->with('error', 'Please upgrade your GitHub connection before submitting plugins.'); + } + // Require developer onboarding and terms acceptance for all plugin submissions $developerAccount = $user->developerAccount; diff --git a/app/Http/Controllers/GitHubAppWebhookController.php b/app/Http/Controllers/GitHubAppWebhookController.php new file mode 100644 index 000000000..af0f537e1 --- /dev/null +++ b/app/Http/Controllers/GitHubAppWebhookController.php @@ -0,0 +1,174 @@ +verifySignature($request)) { + Log::warning('[GitHubAppWebhook] Invalid signature'); + + return response()->json(['error' => 'Invalid signature'], 403); + } + + $event = $request->header('X-GitHub-Event'); + $payload = $request->all(); + + return match ($event) { + 'installation' => $this->handleInstallation($payload), + 'installation_repositories' => $this->handleInstallationRepositories($payload), + default => response()->json(['status' => 'ignored']), + }; + } + + protected function verifySignature(Request $request): bool + { + $secret = config('services.github_app.webhook_secret'); + + if (! $secret) { + return false; + } + + $signature = $request->header('X-Hub-Signature-256'); + + if (! $signature) { + return false; + } + + $expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret); + + return hash_equals($expected, $signature); + } + + protected function handleInstallation(array $payload): JsonResponse + { + $action = $payload['action'] ?? null; + $installation = $payload['installation'] ?? []; + $sender = $payload['sender'] ?? []; + + $installationId = $installation['id'] ?? null; + + if (! $installationId) { + return response()->json(['error' => 'Missing installation ID'], 400); + } + + return match ($action) { + 'created' => $this->installationCreated($installation, $sender), + 'deleted' => $this->installationDeleted($installationId), + 'suspend' => $this->installationSuspended($installationId), + 'unsuspend' => $this->installationUnsuspended($installationId), + default => response()->json(['status' => 'ignored']), + }; + } + + protected function installationCreated(array $installation, array $sender): JsonResponse + { + $user = User::where('github_id', $sender['id'] ?? null)->first(); + + if (! $user) { + Log::info('[GitHubAppWebhook] Installation created by unknown user', [ + 'sender_id' => $sender['id'] ?? null, + 'installation_id' => $installation['id'], + ]); + + return response()->json(['status' => 'user_not_found']); + } + + $repos = collect($installation['repositories'] ?? [])->pluck('full_name')->all(); + + $user->githubInstallations()->updateOrCreate( + ['installation_id' => $installation['id']], + [ + 'account_login' => $installation['account']['login'] ?? 'unknown', + 'account_type' => $installation['account']['type'] ?? 'User', + 'account_id' => $installation['account']['id'] ?? null, + 'selection_type' => $installation['repository_selection'] ?? 'all', + 'repository_selection' => ! empty($repos) ? $repos : null, + ] + ); + + Log::info('[GitHubAppWebhook] Installation created', [ + 'user_id' => $user->id, + 'installation_id' => $installation['id'], + ]); + + return response()->json(['status' => 'created']); + } + + protected function installationDeleted(int $installationId): JsonResponse + { + GitHubInstallation::where('installation_id', $installationId)->delete(); + + Log::info('[GitHubAppWebhook] Installation deleted', [ + 'installation_id' => $installationId, + ]); + + return response()->json(['status' => 'deleted']); + } + + protected function installationSuspended(int $installationId): JsonResponse + { + GitHubInstallation::where('installation_id', $installationId) + ->update(['suspended_at' => now()]); + + return response()->json(['status' => 'suspended']); + } + + protected function installationUnsuspended(int $installationId): JsonResponse + { + GitHubInstallation::where('installation_id', $installationId) + ->update(['suspended_at' => null]); + + return response()->json(['status' => 'unsuspended']); + } + + protected function handleInstallationRepositories(array $payload): JsonResponse + { + $installationId = $payload['installation']['id'] ?? null; + + if (! $installationId) { + return response()->json(['error' => 'Missing installation ID'], 400); + } + + $installation = GitHubInstallation::where('installation_id', $installationId)->first(); + + if (! $installation) { + return response()->json(['status' => 'installation_not_found']); + } + + $selection = $payload['repository_selection'] ?? $installation->selection_type; + $currentRepos = $installation->repository_selection ?? []; + + $addedRepos = collect($payload['repositories_added'] ?? [])->pluck('full_name')->all(); + $removedRepos = collect($payload['repositories_removed'] ?? [])->pluck('full_name')->all(); + + $updatedRepos = array_values( + array_unique( + array_diff( + array_merge($currentRepos, $addedRepos), + $removedRepos + ) + ) + ); + + $installation->update([ + 'selection_type' => $selection, + 'repository_selection' => ! empty($updatedRepos) ? $updatedRepos : null, + ]); + + Log::info('[GitHubAppWebhook] Repositories updated', [ + 'installation_id' => $installationId, + 'added' => $addedRepos, + 'removed' => $removedRepos, + ]); + + return response()->json(['status' => 'updated']); + } +} diff --git a/app/Http/Controllers/GitHubAuthController.php b/app/Http/Controllers/GitHubAuthController.php index 731eb149f..90d9007fc 100644 --- a/app/Http/Controllers/GitHubAuthController.php +++ b/app/Http/Controllers/GitHubAuthController.php @@ -9,10 +9,24 @@ class GitHubAuthController extends Controller { public function redirect(): RedirectResponse { - session(['github_auth_intent' => 'login']); + $driver = $this->resolveDriver(); - return Socialite::driver('github') + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => $driver, + ]); + + return Socialite::driver($driver) ->scopes(['read:user', 'user:email']) ->redirect(); } + + protected function resolveDriver(): string + { + if (config('services.github_app.client_id')) { + return 'github-app'; + } + + return 'github'; + } } diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index de99b114e..f77d99d0c 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -2,12 +2,15 @@ namespace App\Http\Controllers; +use App\Enums\GitHubAuthType; +use App\Models\GitHubInstallation; use App\Models\Product; use App\Models\User; use App\Services\GitHubUserService; use App\Support\GitHubOAuth; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -22,31 +25,42 @@ public function __construct() public function redirectToGitHub(): RedirectResponse { - session(['github_auth_intent' => 'link']); + $driver = $this->resolveDriver(); + + session([ + 'github_auth_intent' => 'link', + 'github_auth_driver' => $driver, + ]); // Store the return URL if provided if (request()->has('return')) { session(['github_return_url' => request()->get('return')]); } - return Socialite::driver('github') - ->scopes(['read:user', 'repo']) + $scopes = $driver === 'github-app' + ? ['read:user', 'user:email'] + : ['read:user', 'repo']; + + return Socialite::driver($driver) + ->scopes($scopes) ->redirect(); } public function handleCallback(): RedirectResponse { try { - $githubUser = Socialite::driver('github')->user(); + $driver = session()->pull('github_auth_driver', 'github'); + $githubUser = Socialite::driver($driver)->user(); $intent = session()->pull('github_auth_intent', 'link'); + $authType = $driver === 'github-app' ? GitHubAuthType::App : GitHubAuthType::OAuth; if (Auth::check()) { - return $this->handleLinkAccount($githubUser); + return $this->handleLinkAccount($githubUser, $authType); } if ($intent === 'login') { - return $this->handleLogin($githubUser); + return $this->handleLogin($githubUser, $authType); } return to_route('customer.login') @@ -64,17 +78,29 @@ public function handleCallback(): RedirectResponse } } - protected function handleLinkAccount($githubUser): RedirectResponse + protected function handleLinkAccount($githubUser, GitHubAuthType $authType): RedirectResponse { $user = Auth::user(); $user->update([ 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, 'github_token' => encrypt($githubUser->token), + 'github_auth_type' => $authType, ]); $returnUrl = session()->pull('github_return_url'); + // For GitHub App users, redirect to install the app for repo access + if ($authType === GitHubAuthType::App && $slug = config('services.github_app.slug')) { + $installUrl = "https://github.com/apps/{$slug}/installations/new"; + + if ($returnUrl) { + session(['github_return_url' => $returnUrl]); + } + + return redirect($installUrl); + } + if ($returnUrl) { return redirect($returnUrl) ->with('success', 'GitHub account connected successfully!'); @@ -84,13 +110,14 @@ protected function handleLinkAccount($githubUser): RedirectResponse ->with('success', 'GitHub account connected successfully!'); } - protected function handleLogin($githubUser): RedirectResponse + protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectResponse { $user = User::where('github_id', $githubUser->id)->first(); if ($user) { $user->update([ 'github_token' => encrypt($githubUser->token), + 'github_auth_type' => $authType, ]); Auth::login($user, remember: true); @@ -106,6 +133,7 @@ protected function handleLogin($githubUser): RedirectResponse 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, 'github_token' => encrypt($githubUser->token), + 'github_auth_type' => $authType, ]); Auth::login($user, remember: true); @@ -120,6 +148,7 @@ protected function handleLogin($githubUser): RedirectResponse 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, 'github_token' => encrypt($githubUser->token), + 'github_auth_type' => $authType, 'password' => bcrypt(Str::random(24)), 'email_verified_at' => now(), ]); @@ -130,6 +159,69 @@ protected function handleLogin($githubUser): RedirectResponse ->with('success', 'Account created successfully!'); } + public function handleSetup(Request $request): RedirectResponse + { + $installationId = $request->query('installation_id'); + + if (! $installationId) { + return to_route('customer.integrations') + ->with('error', 'No installation ID provided.'); + } + + $user = Auth::user(); + + // Record the installation if the webhook hasn't already + $existing = GitHubInstallation::where('installation_id', $installationId)->first(); + + if (! $existing) { + // Fetch installation details from GitHub + try { + $appService = app(\App\Services\GitHubAppService::class); + $jwt = $appService->generateJwt(); + + $response = \Illuminate\Support\Facades\Http::withHeaders([ + 'Authorization' => "Bearer {$jwt}", + 'Accept' => 'application/vnd.github+json', + ])->get("https://api.github.com/app/installations/{$installationId}"); + + if ($response->successful()) { + $data = $response->json(); + + $user->githubInstallations()->create([ + 'installation_id' => $installationId, + 'account_login' => $data['account']['login'] ?? 'unknown', + 'account_type' => $data['account']['type'] ?? 'User', + 'account_id' => $data['account']['id'] ?? null, + 'selection_type' => $data['repository_selection'] ?? 'all', + ]); + } + } catch (\Exception $e) { + Log::warning('Failed to fetch GitHub App installation details', [ + 'installation_id' => $installationId, + 'error' => $e->getMessage(), + ]); + + // Still create a basic record so the user isn't stuck + $user->githubInstallations()->create([ + 'installation_id' => $installationId, + 'account_login' => $user->github_username ?? 'unknown', + 'account_type' => 'User', + 'selection_type' => 'all', + ]); + } + } + + $returnUrl = session()->pull('github_return_url'); + + if ($returnUrl) { + return redirect($returnUrl) + ->with('success', 'GitHub App installed successfully!'); + } + + return to_route('customer.integrations') + ->with('success', 'GitHub App installed successfully!'); + } + public function requestRepoAccess(): RedirectResponse { $user = Auth::user(); @@ -198,10 +290,13 @@ public function disconnect(): RedirectResponse $github->removeFromClaudePluginsRepo($user->github_username); } + $user->githubInstallations()->delete(); + $user->update([ 'github_id' => null, 'github_username' => null, 'github_token' => null, + 'github_auth_type' => null, 'mobile_repo_access_granted_at' => null, 'claude_plugins_repo_access_granted_at' => null, ]); @@ -227,4 +322,13 @@ public function repositories(): JsonResponse 'repositories' => $repositories, ]); } + + protected function resolveDriver(): string + { + if (config('services.github_app.client_id')) { + return 'github-app'; + } + + return 'github'; + } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 6d7c93823..cdabe6d48 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -15,5 +15,6 @@ class VerifyCsrfToken extends Middleware 'stripe/webhook', 'opencollective/contribution', 'webhooks/plugins/*', + 'webhooks/github-app', ]; } diff --git a/app/Jobs/Concerns/ResolvesGitHubToken.php b/app/Jobs/Concerns/ResolvesGitHubToken.php index cf1d8f70c..31453ab4a 100644 --- a/app/Jobs/Concerns/ResolvesGitHubToken.php +++ b/app/Jobs/Concerns/ResolvesGitHubToken.php @@ -3,6 +3,7 @@ namespace App\Jobs\Concerns; use App\Models\Plugin; +use App\Services\GitHubAppService; use Illuminate\Support\Facades\Log; trait ResolvesGitHubToken @@ -12,7 +13,29 @@ protected function getGitHubToken(): ?string /** @var Plugin $plugin */ $plugin = $this->plugin; $user = $plugin->user; + $repo = $plugin->getRepositoryOwnerAndName(); + + // Priority 1: Installation token (GitHub App) + if ($user && $repo && $user->isUsingGitHubApp()) { + $appService = app(GitHubAppService::class); + $installation = $appService->findInstallationForRepo($user, $repo['owner'], $repo['repo']); + + if ($installation) { + $token = $appService->getInstallationToken($installation); + + if ($token) { + Log::debug('[GitHub] Using installation token', [ + 'plugin_id' => $plugin->id, + 'user_id' => $user->id, + 'installation_id' => $installation->installation_id, + ]); + + return $token; + } + } + } + // Priority 2: User OAuth token if ($user && $user->hasGitHubToken()) { Log::debug('[GitHub] Using plugin owner OAuth token', [ 'plugin_id' => $plugin->id, @@ -23,6 +46,7 @@ protected function getGitHubToken(): ?string return $user->getGitHubToken(); } + // Priority 3: Platform token $platformToken = config('services.github.token'); Log::debug('[GitHub] Using platform token fallback', [ diff --git a/app/Livewire/GitHubAppStatus.php b/app/Livewire/GitHubAppStatus.php new file mode 100644 index 000000000..3b58a7adc --- /dev/null +++ b/app/Livewire/GitHubAppStatus.php @@ -0,0 +1,43 @@ +githubInstallations()->orderBy('account_login')->get(); + $plugins = $user->plugins()->get(); + + // Check which plugin repos are covered by installations + $pluginCoverage = []; + foreach ($plugins as $plugin) { + $repo = $plugin->getRepositoryOwnerAndName(); + + if (! $repo) { + continue; + } + + $covered = $installations->contains( + fn ($installation) => $installation->hasAccessToRepo($repo['owner'], $repo['repo']) + ); + + $pluginCoverage[] = [ + 'plugin' => $plugin, + 'owner' => $repo['owner'], + 'repo' => $repo['repo'], + 'covered' => $covered, + ]; + } + + return view('livewire.git-hub-app-status', [ + 'installations' => $installations, + 'pluginCoverage' => $pluginCoverage, + 'slug' => config('services.github_app.slug'), + ]); + } +} diff --git a/app/Models/GitHubInstallation.php b/app/Models/GitHubInstallation.php new file mode 100644 index 000000000..5db89e46e --- /dev/null +++ b/app/Models/GitHubInstallation.php @@ -0,0 +1,83 @@ + */ + use HasFactory; + + protected $table = 'github_installations'; + + protected $guarded = []; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isTokenExpired(): bool + { + if (! $this->token_expires_at) { + return true; + } + + return $this->token_expires_at->isPast(); + } + + public function isSuspended(): bool + { + return $this->suspended_at !== null; + } + + public function hasAccessToRepo(string $owner, string $repo): bool + { + if ($this->isSuspended()) { + return false; + } + + // Account login must match the repo owner + if (strtolower($this->account_login) !== strtolower($owner)) { + return false; + } + + // If "all" repos are selected, grant access + if ($this->selection_type === 'all') { + return true; + } + + // Check if the specific repo is in the selected list + $selectedRepos = $this->repository_selection ?? []; + + return in_array("{$owner}/{$repo}", $selectedRepos, true); + } + + public function getAccessToken(): ?string + { + if (! $this->access_token) { + return null; + } + + try { + return decrypt($this->access_token); + } catch (\Exception) { + return null; + } + } + + protected function casts(): array + { + return [ + 'repository_selection' => 'array', + 'token_expires_at' => 'datetime', + 'suspended_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 0e2b15345..2c6d014b9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,7 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\GitHubAuthType; use Filament\Models\Contracts\FilamentUser; use Filament\Panel; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -257,6 +258,29 @@ public function hasGitHubToken(): bool return $this->getGitHubToken() !== null; } + /** + * @return HasMany + */ + public function githubInstallations(): HasMany + { + return $this->hasMany(GitHubInstallation::class); + } + + public function isUsingGitHubApp(): bool + { + return $this->github_auth_type === GitHubAuthType::App; + } + + public function isUsingLegacyOAuth(): bool + { + return $this->github_auth_type === GitHubAuthType::OAuth; + } + + public function needsGitHubAppMigration(): bool + { + return $this->isUsingLegacyOAuth(); + } + /** * Plugin names that are available for free to eligible subscribers. */ @@ -339,6 +363,7 @@ protected function casts(): array 'mobile_repo_access_granted_at' => 'datetime', 'claude_plugins_repo_access_granted_at' => 'datetime', 'discord_role_granted_at' => 'datetime', + 'github_auth_type' => GitHubAuthType::class, ]; } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fc4217f4b..a460deaad 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -14,6 +14,8 @@ use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Laravel\Pennant\Feature; +use Laravel\Socialite\Facades\Socialite; +use Laravel\Socialite\Two\GithubProvider; use Sentry\State\Scope; use function Sentry\captureException; @@ -40,6 +42,8 @@ public function boot(): void $this->registerFeatureFlags(); + $this->registerGitHubAppSocialiteDriver(); + RateLimiter::for('anystack', function () { return Limit::perMinute(30); }); @@ -87,6 +91,24 @@ private function sendFailingJobsToSentry(): void }); } + private function registerGitHubAppSocialiteDriver(): void + { + $clientId = config('services.github_app.client_id'); + + if (! $clientId) { + return; + } + + Socialite::extend('github-app', function () use ($clientId) { + return new GithubProvider( + $this->app->make('request'), + $clientId, + config('services.github_app.client_secret'), + config('services.github_app.redirect'), + ); + }); + } + private function registerFeatureFlags(): void { Feature::define(ShowAuthButtons::class); diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php new file mode 100644 index 000000000..1b9b7b137 --- /dev/null +++ b/app/Services/GitHubAppService.php @@ -0,0 +1,91 @@ + $now - 60, + 'exp' => $now + (9 * 60), + 'iss' => $appId, + ]; + + return JWT::encode($payload, $privateKey, 'RS256'); + } + + public function getInstallationToken(GitHubInstallation $installation): ?string + { + $cacheKey = "github_installation_token_{$installation->installation_id}"; + + return Cache::remember($cacheKey, now()->addMinutes(55), function () use ($installation) { + return $this->refreshInstallationToken($installation); + }); + } + + public function refreshInstallationToken(GitHubInstallation $installation): ?string + { + try { + $jwt = $this->generateJwt(); + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$jwt}", + 'Accept' => 'application/vnd.github+json', + ])->post("https://api.github.com/app/installations/{$installation->installation_id}/access_tokens"); + + if ($response->failed()) { + Log::warning('[GitHubApp] Failed to get installation token', [ + 'installation_id' => $installation->installation_id, + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return null; + } + + $data = $response->json(); + $token = $data['token']; + $expiresAt = $data['expires_at']; + + $installation->update([ + 'access_token' => encrypt($token), + 'token_expires_at' => $expiresAt, + ]); + + // Update cache with correct TTL + $cacheKey = "github_installation_token_{$installation->installation_id}"; + Cache::put($cacheKey, $token, now()->addMinutes(55)); + + return $token; + } catch (\Exception $e) { + Log::error('[GitHubApp] Exception getting installation token', [ + 'installation_id' => $installation->installation_id, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + public function findInstallationForRepo(User $user, string $owner, string $repo): ?GitHubInstallation + { + return $user->githubInstallations() + ->whereNull('suspended_at') + ->get() + ->first(fn (GitHubInstallation $installation) => $installation->hasAccessToRepo($owner, $repo)); + } +} diff --git a/app/Services/GitHubUserService.php b/app/Services/GitHubUserService.php index a1ca53e3c..2c0c5bb6f 100644 --- a/app/Services/GitHubUserService.php +++ b/app/Services/GitHubUserService.php @@ -19,8 +19,40 @@ public static function for(User $user): static return new static($user); } + public function resolveTokenForRepo(string $owner, string $repo): ?string + { + // Priority 1: Installation token (GitHub App) + if ($this->user->isUsingGitHubApp()) { + $appService = app(GitHubAppService::class); + $installation = $appService->findInstallationForRepo($this->user, $owner, $repo); + + if ($installation) { + $token = $appService->getInstallationToken($installation); + + if ($token) { + return $token; + } + } + } + + // Priority 2: User OAuth token + $userToken = $this->user->getGitHubToken(); + + if ($userToken) { + return $userToken; + } + + // Priority 3: Platform token + return config('services.github.token'); + } + public function getRepositories(bool $includePrivate = true): Collection { + // For GitHub App users, aggregate repos from all installations + if ($this->user->isUsingGitHubApp() && $this->user->githubInstallations()->exists()) { + return $this->getRepositoriesFromInstallations($includePrivate); + } + $token = $this->user->getGitHubToken(); if (! $token) { @@ -34,6 +66,60 @@ public function getRepositories(bool $includePrivate = true): Collection }); } + protected function getRepositoriesFromInstallations(bool $includePrivate): Collection + { + $cacheKey = "github_repos_{$this->user->id}"; + + return Cache::remember($cacheKey, now()->addMinutes(5), function () use ($includePrivate) { + $repos = collect(); + $appService = app(GitHubAppService::class); + + foreach ($this->user->githubInstallations()->whereNull('suspended_at')->get() as $installation) { + $token = $appService->getInstallationToken($installation); + + if (! $token) { + continue; + } + + $page = 1; + $perPage = 100; + + do { + $response = Http::withToken($token) + ->get('https://api.github.com/installation/repositories', [ + 'per_page' => $perPage, + 'page' => $page, + ]); + + if ($response->failed()) { + break; + } + + $pageRepos = collect($response->json('repositories') ?? []); + $repos = $repos->concat($pageRepos); + $page++; + } while ($pageRepos->count() === $perPage && $page <= 10); + } + + if (! $includePrivate) { + $repos = $repos->where('private', false); + } + + return $repos->map(function ($repo) { + return [ + 'id' => $repo['id'], + 'name' => $repo['name'], + 'full_name' => $repo['full_name'], + 'private' => $repo['private'], + 'html_url' => $repo['html_url'], + 'description' => $repo['description'], + 'default_branch' => $repo['default_branch'], + 'pushed_at' => $repo['pushed_at'], + ]; + })->unique('id')->values(); + }); + } + public function clearRepositoryCache(): void { Cache::forget("github_repos_{$this->user->id}"); @@ -90,7 +176,7 @@ protected function fetchRepositories(string $token, bool $includePrivate): Colle public function getRepository(string $owner, string $repo): ?array { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return null; @@ -118,7 +204,7 @@ public function getRepository(string $owner, string $repo): ?array public function getComposerJson(string $owner, string $repo, string $branch = 'main'): ?array { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return null; @@ -151,7 +237,7 @@ public function getComposerJson(string $owner, string $repo, string $branch = 'm */ public function createWebhook(string $owner, string $repo, string $webhookUrl, string $secret): array { - $token = $this->user->getGitHubToken(); + $token = $this->resolveTokenForRepo($owner, $repo); if (! $token) { return [ diff --git a/app/Services/PluginSyncService.php b/app/Services/PluginSyncService.php index d7199c55d..3664d0e90 100644 --- a/app/Services/PluginSyncService.php +++ b/app/Services/PluginSyncService.php @@ -187,11 +187,28 @@ protected function fetchFileFromGitHub(string $owner, string $repo, string $path protected function getGitHubToken(Plugin $plugin): ?string { $user = $plugin->user; + $repo = $plugin->getRepositoryOwnerAndName(); + + // Priority 1: Installation token (GitHub App) + if ($user && $repo && $user->isUsingGitHubApp()) { + $appService = app(GitHubAppService::class); + $installation = $appService->findInstallationForRepo($user, $repo['owner'], $repo['repo']); + + if ($installation) { + $token = $appService->getInstallationToken($installation); + + if ($token) { + return $token; + } + } + } + // Priority 2: User OAuth token if ($user && $user->hasGitHubToken()) { return $user->getGitHubToken(); } + // Priority 3: Platform token return config('services.github.token'); } diff --git a/config/services.php b/config/services.php index bd3734e3f..4d550a5fd 100644 --- a/config/services.php +++ b/config/services.php @@ -50,6 +50,16 @@ 'token' => env('GITHUB_TOKEN'), ], + 'github_app' => [ + 'app_id' => env('GITHUB_APP_ID'), + 'client_id' => env('GITHUB_APP_CLIENT_ID'), + 'client_secret' => env('GITHUB_APP_CLIENT_SECRET'), + 'private_key_path' => env('GITHUB_APP_PRIVATE_KEY_PATH', storage_path('github-app.pem')), + 'webhook_secret' => env('GITHUB_APP_WEBHOOK_SECRET'), + 'redirect' => env('APP_URL').'/auth/github/callback', + 'slug' => env('GITHUB_APP_SLUG'), + ], + 'discord' => [ 'client_id' => env('DISCORD_CLIENT_ID'), 'client_secret' => env('DISCORD_CLIENT_SECRET'), diff --git a/database/factories/GitHubInstallationFactory.php b/database/factories/GitHubInstallationFactory.php new file mode 100644 index 000000000..80d3fa931 --- /dev/null +++ b/database/factories/GitHubInstallationFactory.php @@ -0,0 +1,52 @@ + + */ +class GitHubInstallationFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'installation_id' => fake()->unique()->randomNumber(8), + 'account_login' => fake()->userName(), + 'account_type' => 'User', + 'account_id' => fake()->randomNumber(8), + 'selection_type' => 'all', + 'repository_selection' => null, + ]; + } + + public function forOrganization(): static + { + return $this->state(fn (array $attributes) => [ + 'account_type' => 'Organization', + ]); + } + + public function selectedRepos(array $repos): static + { + return $this->state(fn (array $attributes) => [ + 'selection_type' => 'selected', + 'repository_selection' => $repos, + ]); + } + + public function suspended(): static + { + return $this->state(fn (array $attributes) => [ + 'suspended_at' => now(), + ]); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index ca9d62a88..9246d4ca9 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -36,4 +36,24 @@ public function unverified(): static 'email_verified_at' => null, ]); } + + public function withGitHubApp(): static + { + return $this->state(fn (array $attributes) => [ + 'github_id' => (string) fake()->randomNumber(8), + 'github_username' => fake()->userName(), + 'github_token' => encrypt('ghu_test_token_'.Str::random(20)), + 'github_auth_type' => 'app', + ]); + } + + public function withLegacyGitHub(): static + { + return $this->state(fn (array $attributes) => [ + 'github_id' => (string) fake()->randomNumber(8), + 'github_username' => fake()->userName(), + 'github_token' => encrypt('gho_test_token_'.Str::random(20)), + 'github_auth_type' => 'oauth', + ]); + } } diff --git a/database/migrations/2026_03_23_122642_create_github_installations_table.php b/database/migrations/2026_03_23_122642_create_github_installations_table.php new file mode 100644 index 000000000..02237d4f2 --- /dev/null +++ b/database/migrations/2026_03_23_122642_create_github_installations_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('installation_id')->unique(); + $table->string('account_login'); + $table->string('account_type')->default('User'); + $table->unsignedBigInteger('account_id')->nullable(); + $table->string('selection_type')->default('all'); + $table->json('repository_selection')->nullable(); + $table->text('access_token')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->timestamp('suspended_at')->nullable(); + $table->timestamps(); + + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('github_installations'); + } +}; diff --git a/database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php b/database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php new file mode 100644 index 000000000..45e6b1300 --- /dev/null +++ b/database/migrations/2026_03_23_122647_add_github_auth_type_to_users_table.php @@ -0,0 +1,34 @@ +string('github_auth_type')->nullable()->after('github_token'); + }); + + // Backfill existing users with OAuth tokens + DB::table('users') + ->whereNotNull('github_token') + ->update(['github_auth_type' => 'oauth']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('github_auth_type'); + }); + } +}; diff --git a/resources/views/components/github-migration-banner.blade.php b/resources/views/components/github-migration-banner.blade.php new file mode 100644 index 000000000..bfa1189d5 --- /dev/null +++ b/resources/views/components/github-migration-banner.blade.php @@ -0,0 +1,44 @@ +@props(['blocking' => false]) + +@if(auth()->user()->needsGitHubAppMigration()) +
+
+
+ + + +
+
+

+ GitHub Connection Upgrade Required +

+
+

+ We've upgraded our GitHub integration to use a GitHub App with fine-grained permissions. + Your current connection uses an older OAuth App that requests broader access than needed. +

+ + @if(auth()->user()->plugins()->exists()) +

+ You have submitted plugins. When upgrading, make sure to grant the GitHub App access to your plugin repositories. +

+ @endif + + @if($blocking) +

+ You must upgrade your GitHub connection before you can submit or manage plugins. +

+ @endif +
+ +
+
+
+@endif diff --git a/resources/views/customer/integrations.blade.php b/resources/views/customer/integrations.blade.php index bd95a8419..461e61974 100644 --- a/resources/views/customer/integrations.blade.php +++ b/resources/views/customer/integrations.blade.php @@ -57,6 +57,16 @@ @endif + {{-- GitHub App Migration Banner --}} + + + {{-- GitHub App Status (for users who have migrated) --}} + @if(auth()->user()->isUsingGitHubApp()) +
+ +
+ @endif + {{-- Info Section --}}

About Integrations

diff --git a/resources/views/customer/plugins/create.blade.php b/resources/views/customer/plugins/create.blade.php index c0fece78b..b4eb20e0b 100644 --- a/resources/views/customer/plugins/create.blade.php +++ b/resources/views/customer/plugins/create.blade.php @@ -34,6 +34,10 @@ {{-- Content --}}
+ {{-- GitHub App Migration Banner (blocking) --}} + @if(auth()->user()->needsGitHubAppMigration()) + + @else {{-- Session Error Message --}} @if (session('error'))
@@ -349,6 +353,7 @@ class="inline-flex items-center rounded-md bg-indigo-600 px-4 py-2 text-sm font-
@endif + @endif
diff --git a/resources/views/customer/plugins/index.blade.php b/resources/views/customer/plugins/index.blade.php index d6dfc827a..b5324e368 100644 --- a/resources/views/customer/plugins/index.blade.php +++ b/resources/views/customer/plugins/index.blade.php @@ -23,6 +23,9 @@ {{-- Content --}}
+ {{-- GitHub App Migration Banner --}} + + {{-- Action Cards --}}
{{-- Submit Plugin Card (Most Prominent) --}} diff --git a/resources/views/livewire/git-hub-app-status.blade.php b/resources/views/livewire/git-hub-app-status.blade.php new file mode 100644 index 000000000..a108a381c --- /dev/null +++ b/resources/views/livewire/git-hub-app-status.blade.php @@ -0,0 +1,95 @@ +
+
+
+
+

GitHub App Installations

+

+ Manage which accounts and repositories the NativePHP app can access. +

+
+ @if($slug) + + Add Account + + + + + @endif +
+ + @if($installations->isEmpty()) +
+

+ No GitHub App installations found. Install the app on your GitHub account to grant repository access. +

+ @if($slug) + + + + + Install GitHub App + + @endif +
+ @else +
+ @foreach($installations as $installation) +
+
+
+ @if($installation->account_type === 'Organization') + + + + @else + + + + @endif +
+
+

{{ $installation->account_login }}

+

+ {{ $installation->account_type }} • + {{ $installation->selection_type === 'all' ? 'All repositories' : 'Selected repositories' }} + @if($installation->isSuspended()) + • Suspended + @endif +

+
+
+ + Manage + +
+ @endforeach +
+ @endif + + {{-- Plugin repo coverage --}} + @if(count($pluginCoverage) > 0) +
+

Plugin Repository Access

+
+ @foreach($pluginCoverage as $item) +
+ @if($item['covered']) + + + + @else + + + + @endif + {{ $item['owner'] }}/{{ $item['repo'] }} + @if(!$item['covered']) + Not accessible - update your installation + @endif +
+ @endforeach +
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index 91d25ed56..fa66634f4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -275,12 +275,16 @@ // GitHub OAuth routes (auth required) Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function (): void { Route::get('auth/github', [App\Http\Controllers\GitHubIntegrationController::class, 'redirectToGitHub'])->name('github.redirect'); + Route::get('auth/github/setup', [App\Http\Controllers\GitHubIntegrationController::class, 'handleSetup'])->name('github.setup'); Route::post('customer/github/request-access', [App\Http\Controllers\GitHubIntegrationController::class, 'requestRepoAccess'])->name('github.request-access'); Route::post('customer/github/request-claude-plugins-access', [App\Http\Controllers\GitHubIntegrationController::class, 'requestClaudePluginsAccess'])->name('github.request-claude-plugins-access'); Route::delete('customer/github/disconnect', [App\Http\Controllers\GitHubIntegrationController::class, 'disconnect'])->name('github.disconnect'); Route::get('customer/github/repositories', [App\Http\Controllers\GitHubIntegrationController::class, 'repositories'])->name('github.repositories'); }); +// GitHub App webhook +Route::post('webhooks/github-app', App\Http\Controllers\GitHubAppWebhookController::class)->name('webhooks.github-app'); + // Discord OAuth routes Route::middleware(['auth', EnsureFeaturesAreActive::using(ShowAuthButtons::class)])->group(function (): void { Route::get('auth/discord', [App\Http\Controllers\DiscordIntegrationController::class, 'redirectToDiscord'])->name('discord.redirect'); diff --git a/tests/Feature/GitHubAppAuthTest.php b/tests/Feature/GitHubAppAuthTest.php new file mode 100644 index 000000000..e31c46610 --- /dev/null +++ b/tests/Feature/GitHubAppAuthTest.php @@ -0,0 +1,160 @@ +id = 12345; + $socialiteUser->nickname = 'testuser'; + $socialiteUser->name = 'Test User'; + $socialiteUser->email = 'test@example.com'; + $socialiteUser->token = 'ghu_test_token'; + + $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('github-app') + ->andReturn($provider); + + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => 'github-app', + ]); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(); + + $this->assertDatabaseHas('users', [ + 'github_id' => '12345', + 'github_username' => 'testuser', + 'github_auth_type' => 'app', + ]); + } + + public function test_login_via_legacy_oauth_stores_oauth_auth_type(): void + { + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = 12345; + $socialiteUser->nickname = 'testuser'; + $socialiteUser->name = 'Test User'; + $socialiteUser->email = 'test@example.com'; + $socialiteUser->token = 'gho_test_token'; + + $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('github') + ->andReturn($provider); + + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => 'github', + ]); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(); + + $this->assertDatabaseHas('users', [ + 'github_id' => '12345', + 'github_username' => 'testuser', + 'github_auth_type' => 'oauth', + ]); + } + + public function test_linking_github_app_updates_existing_user_auth_type(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = $user->github_id; + $socialiteUser->nickname = 'newusername'; + $socialiteUser->token = 'ghu_new_token'; + + $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver') + ->with('github-app') + ->andReturn($provider); + + session([ + 'github_auth_intent' => 'link', + 'github_auth_driver' => 'github-app', + ]); + + $this->actingAs($user) + ->get('/auth/github/callback'); + + $user->refresh(); + $this->assertEquals(GitHubAuthType::App, $user->github_auth_type); + $this->assertEquals('newusername', $user->github_username); + } + + public function test_user_model_auth_type_helpers(): void + { + $appUser = User::factory()->withGitHubApp()->create(); + $oauthUser = User::factory()->withLegacyGitHub()->create(); + $noGithubUser = User::factory()->create(); + + $this->assertTrue($appUser->isUsingGitHubApp()); + $this->assertFalse($appUser->isUsingLegacyOAuth()); + $this->assertFalse($appUser->needsGitHubAppMigration()); + + $this->assertFalse($oauthUser->isUsingGitHubApp()); + $this->assertTrue($oauthUser->isUsingLegacyOAuth()); + $this->assertTrue($oauthUser->needsGitHubAppMigration()); + + $this->assertFalse($noGithubUser->isUsingGitHubApp()); + $this->assertFalse($noGithubUser->isUsingLegacyOAuth()); + $this->assertFalse($noGithubUser->needsGitHubAppMigration()); + } + + public function test_github_app_redirect_uses_app_driver_when_configured(): void + { + config(['services.github_app.client_id' => 'test_client_id']); + + $user = User::factory()->create(); + + $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider->shouldReceive('scopes') + ->with(['read:user', 'user:email']) + ->andReturnSelf(); + $provider->shouldReceive('redirect') + ->andReturn(redirect('https://github.com/login/oauth/authorize')); + + Socialite::shouldReceive('driver') + ->with('github-app') + ->andReturn($provider); + + $response = $this->actingAs($user) + ->get('/auth/github'); + + $response->assertRedirect(); + } +} diff --git a/tests/Feature/GitHubAppWebhookTest.php b/tests/Feature/GitHubAppWebhookTest.php new file mode 100644 index 000000000..18c0f7612 --- /dev/null +++ b/tests/Feature/GitHubAppWebhookTest.php @@ -0,0 +1,218 @@ + $this->webhookSecret]); + } + + protected function signPayload(string $payload): string + { + return 'sha256='.hash_hmac('sha256', $payload, $this->webhookSecret); + } + + public function test_webhook_rejects_invalid_signature(): void + { + $response = $this->postJson('/webhooks/github-app', ['action' => 'created'], [ + 'X-GitHub-Event' => 'installation', + 'X-Hub-Signature-256' => 'sha256=invalid', + ]); + + $response->assertStatus(403); + } + + public function test_webhook_rejects_missing_signature(): void + { + $response = $this->postJson('/webhooks/github-app', ['action' => 'created'], [ + 'X-GitHub-Event' => 'installation', + ]); + + $response->assertStatus(403); + } + + public function test_installation_created_event_creates_record(): void + { + $user = User::factory()->withGitHubApp()->create(['github_id' => '99999']); + + $payload = [ + 'action' => 'created', + 'installation' => [ + 'id' => 12345, + 'account' => [ + 'login' => 'testuser', + 'type' => 'User', + 'id' => 54321, + ], + 'repository_selection' => 'all', + 'repositories' => [], + ], + 'sender' => [ + 'id' => 99999, + ], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $response->assertJson(['status' => 'created']); + + $this->assertDatabaseHas('github_installations', [ + 'user_id' => $user->id, + 'installation_id' => 12345, + 'account_login' => 'testuser', + 'account_type' => 'User', + 'selection_type' => 'all', + ]); + } + + public function test_installation_deleted_event_removes_record(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + ]); + + $payload = [ + 'action' => 'deleted', + 'installation' => [ + 'id' => 12345, + 'account' => ['login' => 'testuser', 'type' => 'User', 'id' => 1], + 'repository_selection' => 'all', + ], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $this->assertDatabaseMissing('github_installations', ['installation_id' => 12345]); + } + + public function test_installation_suspend_event_updates_record(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + ]); + + $payload = [ + 'action' => 'suspend', + 'installation' => [ + 'id' => 12345, + 'account' => ['login' => 'testuser', 'type' => 'User', 'id' => 1], + 'repository_selection' => 'all', + ], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $installation->refresh(); + $this->assertNotNull($installation->suspended_at); + } + + public function test_installation_unsuspend_event_clears_suspension(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->suspended()->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + ]); + + $payload = [ + 'action' => 'unsuspend', + 'installation' => [ + 'id' => 12345, + 'account' => ['login' => 'testuser', 'type' => 'User', 'id' => 1], + 'repository_selection' => 'all', + ], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $installation->refresh(); + $this->assertNull($installation->suspended_at); + } + + public function test_installation_repositories_event_updates_repos(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->selectedRepos(['testuser/repo-a'])->create([ + 'user_id' => $user->id, + 'installation_id' => 12345, + 'account_login' => 'testuser', + ]); + + $payload = [ + 'action' => 'added', + 'installation' => ['id' => 12345], + 'repository_selection' => 'selected', + 'repositories_added' => [ + ['full_name' => 'testuser/repo-b'], + ], + 'repositories_removed' => [], + 'sender' => ['id' => 99999], + ]; + + $payloadJson = json_encode($payload); + $signature = $this->signPayload($payloadJson); + + $response = $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => 'installation_repositories', + 'HTTP_X_HUB_SIGNATURE_256' => $signature, + 'CONTENT_TYPE' => 'application/json', + ], $payloadJson); + + $response->assertOk(); + $installation->refresh(); + $this->assertContains('testuser/repo-a', $installation->repository_selection); + $this->assertContains('testuser/repo-b', $installation->repository_selection); + } +} diff --git a/tests/Feature/GitHubMigrationBannerTest.php b/tests/Feature/GitHubMigrationBannerTest.php new file mode 100644 index 000000000..498118e69 --- /dev/null +++ b/tests/Feature/GitHubMigrationBannerTest.php @@ -0,0 +1,100 @@ + Http::response([], 404)]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $response = $this->actingAs($user)->get('/customer/integrations'); + + $response->assertStatus(200); + $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee('Upgrade GitHub Connection'); + } + + public function test_github_app_user_does_not_see_migration_banner(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->withGitHubApp()->create(); + + $response = $this->actingAs($user)->get('/customer/integrations'); + + $response->assertStatus(200); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_user_without_github_does_not_see_migration_banner(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->create(); + + $response = $this->actingAs($user)->get('/customer/integrations'); + + $response->assertStatus(200); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_legacy_oauth_user_sees_banner_on_plugins_index(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $response = $this->actingAs($user)->get('/customer/plugins'); + + $response->assertStatus(200); + $response->assertSee('GitHub Connection Upgrade Required'); + } + + public function test_legacy_oauth_user_is_blocked_from_plugin_submission(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $response = $this->actingAs($user) + ->post('/customer/plugins', [ + 'repository' => 'testuser/test-plugin', + 'type' => 'free', + ]); + + $response->assertRedirect(route('customer.integrations')); + $response->assertSessionHas('error', 'Please upgrade your GitHub connection before submitting plugins.'); + } + + public function test_legacy_oauth_user_sees_blocking_banner_on_plugin_create(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $response = $this->actingAs($user)->get('/customer/plugins/submit'); + + $response->assertStatus(200); + $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee('You must upgrade your GitHub connection before you can submit or manage plugins.'); + } +} diff --git a/tests/Feature/GitHubTokenResolutionTest.php b/tests/Feature/GitHubTokenResolutionTest.php new file mode 100644 index 000000000..fcd557338 --- /dev/null +++ b/tests/Feature/GitHubTokenResolutionTest.php @@ -0,0 +1,86 @@ +withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'testowner', + 'selection_type' => 'all', + ]); + + $appService = Mockery::mock(GitHubAppService::class); + $appService->shouldReceive('findInstallationForRepo') + ->with($user, 'testowner', 'testrepo') + ->andReturn($installation); + $appService->shouldReceive('getInstallationToken') + ->with($installation) + ->andReturn('ghs_installation_token'); + + $this->app->instance(GitHubAppService::class, $appService); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + $this->assertEquals('ghs_installation_token', $token); + } + + public function test_falls_back_to_user_oauth_token_when_no_installation(): void + { + $user = User::factory()->withGitHubApp()->create(); + + $appService = Mockery::mock(GitHubAppService::class); + $appService->shouldReceive('findInstallationForRepo') + ->andReturn(null); + + $this->app->instance(GitHubAppService::class, $appService); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + // Should get the user's OAuth token + $this->assertNotNull($token); + $this->assertStringStartsWith('ghu_test_token_', $token); + } + + public function test_falls_back_to_platform_token_when_no_user_token(): void + { + $user = User::factory()->create([ + 'github_auth_type' => null, + 'github_token' => null, + ]); + + config(['services.github.token' => 'ghp_platform_token']); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + $this->assertEquals('ghp_platform_token', $token); + } + + public function test_legacy_oauth_user_uses_oauth_token_directly(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $service = GitHubUserService::for($user); + $token = $service->resolveTokenForRepo('testowner', 'testrepo'); + + // Should get the user's OAuth token directly (no installation lookup) + $this->assertNotNull($token); + $this->assertStringStartsWith('gho_test_token_', $token); + } +} diff --git a/tests/Unit/GitHubAppServiceTest.php b/tests/Unit/GitHubAppServiceTest.php new file mode 100644 index 000000000..e48ca7d00 --- /dev/null +++ b/tests/Unit/GitHubAppServiceTest.php @@ -0,0 +1,92 @@ +withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + + $this->assertNotNull($found); + $this->assertEquals($installation->id, $found->id); + } + + public function test_find_installation_for_repo_with_selected_repos(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->selectedRepos(['testuser/my-plugin'])->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + ]); + + $service = new GitHubAppService; + + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + $this->assertNotNull($found); + + $notFound = $service->findInstallationForRepo($user, 'testuser', 'other-repo'); + $this->assertNull($notFound); + } + + public function test_find_installation_for_repo_ignores_suspended(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->suspended()->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + + $this->assertNull($found); + } + + public function test_find_installation_for_repo_returns_null_for_wrong_owner(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'testuser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'otheruser', 'my-plugin'); + + $this->assertNull($found); + } + + public function test_find_installation_for_repo_case_insensitive_owner(): void + { + $user = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'TestUser', + 'selection_type' => 'all', + ]); + + $service = new GitHubAppService; + $found = $service->findInstallationForRepo($user, 'testuser', 'my-plugin'); + + $this->assertNotNull($found); + } +} From 06ef67cf6c6b5806a78527ee8ecf80ee68388e62 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 24 Sep 2026 11:21:57 +0100 Subject: [PATCH 2/5] Send plugin authors to install the GitHub App after login Signing in with the GitHub App switched legacy users over without ever asking them to install the app, which quietly broke syncing for their plugin repos. Login now redirects anyone with uncovered plugin repos to the install page, the banner lists repos the app can't reach, and the create page prompts for an install instead of showing an empty repo list. Also clears the cached repo list when an installation is recorded or its repos change. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../GitHubAppWebhookController.php | 5 + .../GitHubIntegrationController.php | 30 +- app/Models/User.php | 35 +++ app/Services/GitHubAppService.php | 10 + .../github-migration-banner.blade.php | 53 +++- .../customer/plugins/create.blade.php | 23 ++ .../GitHubAppInstallationPromptTest.php | 259 ++++++++++++++++++ 7 files changed, 397 insertions(+), 18 deletions(-) create mode 100644 tests/Feature/GitHubAppInstallationPromptTest.php diff --git a/app/Http/Controllers/GitHubAppWebhookController.php b/app/Http/Controllers/GitHubAppWebhookController.php index af0f537e1..ffd74e34e 100644 --- a/app/Http/Controllers/GitHubAppWebhookController.php +++ b/app/Http/Controllers/GitHubAppWebhookController.php @@ -4,6 +4,7 @@ use App\Models\GitHubInstallation; use App\Models\User; +use App\Services\GitHubUserService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -94,6 +95,8 @@ protected function installationCreated(array $installation, array $sender): Json ] ); + GitHubUserService::for($user)->clearRepositoryCache(); + Log::info('[GitHubAppWebhook] Installation created', [ 'user_id' => $user->id, 'installation_id' => $installation['id'], @@ -163,6 +166,8 @@ protected function handleInstallationRepositories(array $payload): JsonResponse 'repository_selection' => ! empty($updatedRepos) ? $updatedRepos : null, ]); + GitHubUserService::for($installation->user)->clearRepositoryCache(); + Log::info('[GitHubAppWebhook] Repositories updated', [ 'installation_id' => $installationId, 'added' => $addedRepos, diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index 1faa769f6..335a6b467 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -94,9 +94,7 @@ protected function handleLinkAccount($githubUser, GitHubAuthType $authType): Red $returnUrl = session()->pull('github_return_url'); // For GitHub App users, redirect to install the app for repo access - if ($authType === GitHubAuthType::App && $slug = config('services.github_app.slug')) { - $installUrl = "https://github.com/apps/{$slug}/installations/new"; - + if ($authType === GitHubAuthType::App && $installUrl = app(GitHubAppService::class)->installationUrl()) { if ($returnUrl) { session(['github_return_url' => $returnUrl]); } @@ -125,8 +123,7 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR Auth::login($user, remember: true); - return redirect()->intended(route('dashboard')) - ->with('success', 'Welcome back!'); + return $this->redirectAfterLogin($user, $authType, 'Welcome back!'); } $user = User::where('email', $githubUser->email)->first(); @@ -141,8 +138,7 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR Auth::login($user, remember: true); - return redirect()->intended(route('dashboard')) - ->with('success', 'GitHub account connected and logged in!'); + return $this->redirectAfterLogin($user, $authType, 'GitHub account connected and logged in!'); } $user = User::create([ @@ -162,6 +158,24 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR ->with('success', 'Account created successfully!'); } + /** + * Send plugin authors whose repositories the GitHub App can't reach to the installation + * page, so signing in with the app never silently cuts off their plugin syncing. + */ + protected function redirectAfterLogin(User $user, GitHubAuthType $authType, string $message): RedirectResponse + { + $installUrl = app(GitHubAppService::class)->installationUrl(); + + if ($authType === GitHubAuthType::App && $installUrl && $user->pluginsMissingGitHubAppAccess()->isNotEmpty()) { + session(['github_return_url' => session()->pull('url.intended', route('dashboard'))]); + + return redirect($installUrl); + } + + return redirect()->intended(route('dashboard')) + ->with('success', $message); + } + public function handleSetup(Request $request): RedirectResponse { $installationId = $request->query('installation_id'); @@ -214,6 +228,8 @@ public function handleSetup(Request $request): RedirectResponse } } + GitHubUserService::for($user)->clearRepositoryCache(); + $returnUrl = session()->pull('github_return_url'); if ($returnUrl) { diff --git a/app/Models/User.php b/app/Models/User.php index 7178c01b8..f6ac6fe4f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -549,6 +549,41 @@ public function needsGitHubAppMigration(): bool return $this->isUsingLegacyOAuth(); } + /** + * Whether the user has signed in with the GitHub App but hasn't installed it anywhere yet. + */ + public function needsGitHubAppInstallation(): bool + { + return $this->isUsingGitHubApp() + && ! $this->githubInstallations()->whereNull('suspended_at')->exists(); + } + + /** + * Plugins whose repositories aren't reachable through any of the user's active GitHub App installations. + * + * @return \Illuminate\Database\Eloquent\Collection + */ + public function pluginsMissingGitHubAppAccess(): \Illuminate\Database\Eloquent\Collection + { + if (! $this->isUsingGitHubApp()) { + return new \Illuminate\Database\Eloquent\Collection; + } + + $installations = $this->githubInstallations()->whereNull('suspended_at')->get(); + + return $this->plugins() + ->whereNotNull('repository_url') + ->get() + ->filter(function (Plugin $plugin) use ($installations): bool { + $repo = $plugin->getRepositoryOwnerAndName(); + + return $repo && ! $installations->contains( + fn (GitHubInstallation $installation): bool => $installation->hasAccessToRepo($repo['owner'], $repo['repo']) + ); + }) + ->values(); + } + /** * Plugin names that are available for free to eligible subscribers. */ diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php index 1b9b7b137..9be0d214b 100644 --- a/app/Services/GitHubAppService.php +++ b/app/Services/GitHubAppService.php @@ -11,6 +11,16 @@ class GitHubAppService { + /** + * The GitHub page where a user installs the app or changes which repositories it can access. + */ + public function installationUrl(): ?string + { + $slug = config('services.github_app.slug'); + + return $slug ? "https://github.com/apps/{$slug}/installations/new" : null; + } + public function generateJwt(): string { $privateKeyPath = config('services.github_app.private_key_path'); diff --git a/resources/views/components/github-migration-banner.blade.php b/resources/views/components/github-migration-banner.blade.php index bfa1189d5..f21ce2081 100644 --- a/resources/views/components/github-migration-banner.blade.php +++ b/resources/views/components/github-migration-banner.blade.php @@ -1,7 +1,20 @@ @props(['blocking' => false]) -@if(auth()->user()->needsGitHubAppMigration()) -
+@php + $user = auth()->user(); + $needsMigration = $user->needsGitHubAppMigration(); + $missingAccess = $needsMigration ? collect() : $user->pluginsMissingGitHubAppAccess(); + $pluginRepos = ($needsMigration ? $user->plugins()->whereNotNull('repository_url')->get() : $missingAccess) + ->map(fn ($plugin) => $plugin->getRepositoryOwnerAndName()) + ->filter() + ->map(fn (array $repo) => "{$repo['owner']}/{$repo['repo']}") + ->unique() + ->values(); + $installUrl = app(\App\Services\GitHubAppService::class)->installationUrl(); +@endphp + +@if($needsMigration || $missingAccess->isNotEmpty()) +
@@ -10,18 +23,34 @@

- GitHub Connection Upgrade Required + {{ $needsMigration ? 'GitHub Connection Upgrade Required' : 'GitHub App Needs Access to Your Plugins' }}

-

- We've upgraded our GitHub integration to use a GitHub App with fine-grained permissions. - Your current connection uses an older OAuth App that requests broader access than needed. -

+ @if($needsMigration) +

+ We've upgraded our GitHub integration to use a GitHub App with fine-grained permissions. + Your current connection uses an older OAuth App that requests broader access than needed. +

+ @else +

+ We can't reach some of your plugin repositories, so they won't sync new releases. + Install the NativePHP GitHub App on the accounts that own them, or add them to an existing installation. +

+ @endif - @if(auth()->user()->plugins()->exists()) + @if($pluginRepos->isNotEmpty())

- You have submitted plugins. When upgrading, make sure to grant the GitHub App access to your plugin repositories. + @if($needsMigration) + When you upgrade, make sure the GitHub App can access these repositories. If you choose "Only select repositories", include each of them: + @else + Grant access to: + @endif

+
    + @foreach($pluginRepos as $repo) +
  • {{ $repo }}
  • + @endforeach +
@endif @if($blocking) @@ -30,14 +59,16 @@

@endif
+ @if($needsMigration || $installUrl) + @endif
diff --git a/resources/views/livewire/customer/plugins/create.blade.php b/resources/views/livewire/customer/plugins/create.blade.php index 832e2d745..b0c96c50c 100644 --- a/resources/views/livewire/customer/plugins/create.blade.php +++ b/resources/views/livewire/customer/plugins/create.blade.php @@ -44,6 +44,23 @@ + {{-- GitHub App Installation Required --}} + @elseif (auth()->user()->needsGitHubAppInstallation()) + + Install the GitHub App + + To create a plugin, install the NativePHP GitHub App on the account that owns your plugin's repository. + You can choose to give it access to only the repositories you want to publish. + + @if ($installUrl = app(\App\Services\GitHubAppService::class)->installationUrl()) + + + + Install GitHub App + + + @endif + @else
{{-- Plugin Type --}} @@ -119,6 +136,12 @@ @endforeach @endif + + @if (auth()->user()->isUsingGitHubApp() && $installUrl = app(\App\Services\GitHubAppService::class)->installationUrl()) + + Don't see your repository? Give the GitHub App access to it, then reload this page. + + @endif @endif
diff --git a/tests/Feature/GitHubAppInstallationPromptTest.php b/tests/Feature/GitHubAppInstallationPromptTest.php new file mode 100644 index 000000000..b6daced6e --- /dev/null +++ b/tests/Feature/GitHubAppInstallationPromptTest.php @@ -0,0 +1,259 @@ + 'nativephp-test']); + + Http::fake(['api.github.com/*' => Http::response([], 404)]); + } + + private function fakeGitHubAppLogin(User $user): void + { + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = $user->github_id; + $socialiteUser->nickname = $user->github_username; + $socialiteUser->name = $user->name; + $socialiteUser->email = $user->email; + $socialiteUser->token = 'ghu_new_token'; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver')->with('github-app')->andReturn($provider); + + session([ + 'github_auth_intent' => 'login', + 'github_auth_driver' => 'github-app', + ]); + } + + private function pluginFor(User $user, string $repository): Plugin + { + return Plugin::factory()->create([ + 'user_id' => $user->id, + 'repository_url' => "https://github.com/{$repository}", + ]); + } + + public function test_plugins_missing_access_lists_repos_without_a_covering_installation(): void + { + $user = User::factory()->withGitHubApp()->create(); + $covered = $this->pluginFor($user, 'acme/covered-plugin'); + $uncovered = $this->pluginFor($user, 'other-org/uncovered-plugin'); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + $missing = $user->pluginsMissingGitHubAppAccess(); + + $this->assertTrue($missing->contains($uncovered)); + $this->assertFalse($missing->contains($covered)); + } + + public function test_plugins_missing_access_respects_selected_repositories(): void + { + $user = User::factory()->withGitHubApp()->create(); + $selected = $this->pluginFor($user, 'acme/selected-plugin'); + $notSelected = $this->pluginFor($user, 'acme/not-selected-plugin'); + + GitHubInstallation::factory()->selectedRepos(['acme/selected-plugin'])->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + ]); + + $missing = $user->pluginsMissingGitHubAppAccess(); + + $this->assertFalse($missing->contains($selected)); + $this->assertTrue($missing->contains($notSelected)); + } + + public function test_suspended_installations_do_not_count_as_access(): void + { + $user = User::factory()->withGitHubApp()->create(); + $plugin = $this->pluginFor($user, 'acme/some-plugin'); + + GitHubInstallation::factory()->suspended()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + ]); + + $this->assertTrue($user->pluginsMissingGitHubAppAccess()->contains($plugin)); + $this->assertTrue($user->needsGitHubAppInstallation()); + } + + public function test_plugins_missing_access_is_empty_for_legacy_oauth_users(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $this->assertTrue($user->pluginsMissingGitHubAppAccess()->isEmpty()); + $this->assertFalse($user->needsGitHubAppInstallation()); + } + + public function test_legacy_plugin_author_logging_in_with_github_app_is_sent_to_install_the_app(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $this->fakeGitHubAppLogin($user); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(self::INSTALL_URL); + $response->assertSessionHas('github_return_url', route('dashboard')); + $this->assertAuthenticatedAs($user); + $this->assertEquals(GitHubAuthType::App, $user->fresh()->github_auth_type); + } + + public function test_install_redirect_preserves_the_intended_url(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $this->fakeGitHubAppLogin($user); + session(['url.intended' => route('customer.plugins.index')]); + + $response = $this->get('/auth/github/callback'); + + $response->assertRedirect(self::INSTALL_URL); + $response->assertSessionHas('github_return_url', route('customer.plugins.index')); + } + + public function test_user_without_plugins_logging_in_with_github_app_goes_to_the_dashboard(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $this->fakeGitHubAppLogin($user); + + $this->get('/auth/github/callback')->assertRedirect(route('dashboard')); + } + + public function test_plugin_author_with_covered_repos_logging_in_goes_to_the_dashboard(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + $this->fakeGitHubAppLogin($user); + + $this->get('/auth/github/callback')->assertRedirect(route('dashboard')); + } + + public function test_github_app_user_sees_which_plugin_repos_need_access(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertOk(); + $response->assertSee('GitHub App Needs Access to Your Plugins'); + $response->assertSee('acme/some-plugin'); + $response->assertSee(self::INSTALL_URL); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_github_app_user_with_covered_repos_sees_no_access_banner(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/some-plugin'); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + $response = $this->actingAs($user)->get(route('customer.plugins.index')); + + $response->assertOk(); + $response->assertDontSee('GitHub App Needs Access to Your Plugins'); + } + + public function test_legacy_upgrade_banner_names_the_repos_to_grant(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + $this->pluginFor($user, 'acme/paid-plugin'); + + $response = $this->actingAs($user)->get(route('customer.integrations')); + + $response->assertOk(); + $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee('acme/paid-plugin'); + } + + public function test_create_page_prompts_github_app_user_without_installation_to_install(): void + { + $user = User::factory()->withGitHubApp()->create(); + + $response = $this->actingAs($user)->get(route('customer.plugins.create')); + + $response->assertOk(); + $response->assertSee('Install the GitHub App'); + $response->assertSee(self::INSTALL_URL); + } + + public function test_create_page_shows_form_once_the_app_is_installed(): void + { + $user = User::factory()->withGitHubApp()->create(); + + GitHubInstallation::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get(route('customer.plugins.create')); + + $response->assertOk(); + $response->assertDontSee('Install the GitHub App'); + $response->assertSee('Select Repository'); + } + + public function test_setup_callback_clears_the_cached_repository_list(): void + { + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->create(['user_id' => $user->id]); + + Cache::put("github_repos_{$user->id}", collect(), now()->addMinutes(5)); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => $installation->installation_id])) + ->assertRedirect(route('customer.integrations')); + + $this->assertFalse(Cache::has("github_repos_{$user->id}")); + } +} From c11b4b9e6aef9a08584ed6f5440ff062304c509f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 24 Sep 2026 14:51:15 +0100 Subject: [PATCH 3/5] Harden GitHub App setup and add migration tooling - Verify the installation_id on the setup redirect against the user's own installations, and fetch the repo list during setup - Add github:sync-installations (daily) to catch missed webhooks - Store refresh tokens and renew expired GitHub App user tokens - Revoke the legacy OAuth grant when someone moves to the app - Handle push and release events through the app webhook, so covered plugins no longer need a per-repo webhook - Add github:send-app-migration-notice (with --preview) and the email - Add GITHUB_LEGACY_OAUTH_CUTOFF_DATE: legacy tokens are ignored after it, and github:retire-legacy-oauth clears them - Banner: urgent for plugin authors, a soft note for everyone else - GitHub connection column and filters in the Filament users table Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 2 + .../Commands/RetireLegacyGitHubOAuth.php | 81 +++++++ .../Commands/SendGitHubAppMigrationNotice.php | 69 ++++++ .../Commands/SyncGitHubInstallations.php | 44 ++++ app/Console/Kernel.php | 6 + app/Filament/Resources/UserResource.php | 22 +- .../GitHubAppWebhookController.php | 34 +++ .../GitHubIntegrationController.php | 113 +++++----- app/Livewire/Customer/Plugins/Show.php | 17 +- app/Models/Plugin.php | 16 ++ app/Models/User.php | 42 ++++ .../GitHubAppMigrationRequired.php | 39 ++++ app/Services/GitHubAppService.php | 206 ++++++++++++++++++ app/Support/GitHubOAuth.php | 37 ++++ config/services.php | 2 + ...en_and_migration_fields_to_users_table.php | 30 +++ .../github-migration-banner.blade.php | 87 +++++--- .../livewire/customer/integrations.blade.php | 5 +- tests/Concerns/InteractsWithGitHubApp.php | 34 +++ .../GitHubAppInstallationPromptTest.php | 44 ++++ .../Feature/GitHubAppMigrationNoticeTest.php | 145 ++++++++++++ .../Feature/GitHubAppRepositoryEventsTest.php | 171 +++++++++++++++ tests/Feature/GitHubAppSetupTest.php | 179 +++++++++++++++ tests/Feature/GitHubAppUserTokenTest.php | 196 +++++++++++++++++ .../GitHubLegacyOAuthRetirementTest.php | 118 ++++++++++ tests/Feature/GitHubMigrationBannerTest.php | 26 ++- tests/Unit/GitHubAppServiceTest.php | 17 ++ 27 files changed, 1694 insertions(+), 88 deletions(-) create mode 100644 app/Console/Commands/RetireLegacyGitHubOAuth.php create mode 100644 app/Console/Commands/SendGitHubAppMigrationNotice.php create mode 100644 app/Console/Commands/SyncGitHubInstallations.php create mode 100644 app/Notifications/GitHubAppMigrationRequired.php create mode 100644 database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php create mode 100644 tests/Concerns/InteractsWithGitHubApp.php create mode 100644 tests/Feature/GitHubAppMigrationNoticeTest.php create mode 100644 tests/Feature/GitHubAppRepositoryEventsTest.php create mode 100644 tests/Feature/GitHubAppSetupTest.php create mode 100644 tests/Feature/GitHubAppUserTokenTest.php create mode 100644 tests/Feature/GitHubLegacyOAuthRetirementTest.php diff --git a/.env.example b/.env.example index f192f8569..8697a1b2e 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,8 @@ TURNSTILE_HOSTNAMES= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= GITHUB_TOKEN= +# When the legacy OAuth App stops working, e.g. 2026-12-31. Shown in the upgrade banner and email. +GITHUB_LEGACY_OAUTH_CUTOFF_DATE= GITHUB_APP_ID= GITHUB_APP_CLIENT_ID= diff --git a/app/Console/Commands/RetireLegacyGitHubOAuth.php b/app/Console/Commands/RetireLegacyGitHubOAuth.php new file mode 100644 index 000000000..7ccfa41ae --- /dev/null +++ b/app/Console/Commands/RetireLegacyGitHubOAuth.php @@ -0,0 +1,81 @@ +option('dry-run'); + $force = $this->option('force'); + + if (! $force && ! $appService->legacyOAuthHasBeenRetired()) { + $cutoffDate = $appService->legacyOAuthCutoffDate(); + + $this->error($cutoffDate + ? "The cutoff date ({$cutoffDate->format('j F Y')}) hasn't passed yet. Use --force to run it anyway." + : 'GITHUB_LEGACY_OAUTH_CUTOFF_DATE isn\'t set. Set it, or use --force to run it anyway.'); + + return self::FAILURE; + } + + $users = User::query() + ->where('github_auth_type', GitHubAuthType::OAuth) + ->whereNotNull('github_token') + ->with('plugins') + ->get(); + + $authors = $users->filter(fn (User $user): bool => $user->plugins->isNotEmpty()); + + $this->info("Found {$users->count()} user(s) with a legacy OAuth token, {$authors->count()} of them plugin authors"); + + if ($authors->isNotEmpty()) { + $this->newLine(); + $this->warn('These plugin authors never connected the GitHub App. Their plugins will only sync if the repo is public:'); + $this->table( + ['User', 'Email', 'Plugins'], + $authors->map(fn (User $user): array => [ + $user->id, + $user->email, + $user->plugins->pluck('name')->filter()->implode(', '), + ]) + ); + } + + if ($dryRun) { + $this->info('DRY RUN - No tokens were cleared'); + + return self::SUCCESS; + } + + if (! $force && ! $this->confirm("Clear {$users->count()} legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.")) { + $this->info('Nothing changed.'); + + return self::SUCCESS; + } + + $cleared = User::query() + ->whereKey($users->modelKeys()) + ->update([ + 'github_token' => null, + 'github_refresh_token' => null, + 'github_token_expires_at' => null, + ]); + + $this->info("Cleared {$cleared} legacy token(s)."); + $this->line('If you haven\'t already, delete the old OAuth App on GitHub and remove GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET.'); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SendGitHubAppMigrationNotice.php b/app/Console/Commands/SendGitHubAppMigrationNotice.php new file mode 100644 index 000000000..cbd4ef446 --- /dev/null +++ b/app/Console/Commands/SendGitHubAppMigrationNotice.php @@ -0,0 +1,69 @@ +option('preview')) { + Notification::route('mail', $preview)->notifyNow(new GitHubAppMigrationRequired); + + $this->info("Sent a preview to {$preview}"); + + return self::SUCCESS; + } + + $dryRun = $this->option('dry-run'); + + if (! $dryRun && ! $appService->legacyOAuthCutoffDate()) { + $this->error('Set GITHUB_LEGACY_OAUTH_CUTOFF_DATE first, the email tells plugin authors when the old connection stops working.'); + + return self::FAILURE; + } + + if ($dryRun) { + $this->info('DRY RUN - No emails will be sent'); + } + + $users = User::query() + ->where('github_auth_type', GitHubAuthType::OAuth) + ->whereNull('github_app_migration_notified_at') + ->whereNotNull('email_verified_at') + ->withCount('plugins') + ->get(); + + $this->info("Found {$users->count()} user(s) still on the legacy OAuth App who haven't been emailed, {$users->where('plugins_count', '>', 0)->count()} of them plugin authors"); + + foreach ($users as $user) { + if ($dryRun) { + $this->line("Would send to: {$user->email} ({$user->plugins_count} plugin(s))"); + + continue; + } + + $user->notify(new GitHubAppMigrationRequired); + $user->update(['github_app_migration_notified_at' => now()]); + + $this->line("Sent to: {$user->email}"); + } + + $this->newLine(); + $this->info($dryRun ? "Would send: {$users->count()} email(s)" : "Sent: {$users->count()} email(s)"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SyncGitHubInstallations.php b/app/Console/Commands/SyncGitHubInstallations.php new file mode 100644 index 000000000..f45935317 --- /dev/null +++ b/app/Console/Commands/SyncGitHubInstallations.php @@ -0,0 +1,44 @@ +with('user')->chunkById(100, function ($installations) use ($appService, &$synced, &$removed, &$failed): void { + foreach ($installations as $installation) { + if ($appService->syncInstallation($installation)) { + $synced++; + } elseif (! $installation->exists) { + $removed++; + $this->line("Removed installation {$installation->installation_id} ({$installation->account_login}), GitHub no longer has it"); + } else { + $failed++; + $this->error("Failed to sync installation {$installation->installation_id} ({$installation->account_login})"); + } + + if ($installation->user) { + GitHubUserService::for($installation->user)->clearRepositoryCache(); + } + } + }); + + $this->info("Synced: {$synced}, removed: {$removed}, failed: {$failed}"); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 55812c394..08fe3bb37 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -18,6 +18,12 @@ protected function schedule(Schedule $schedule): void ->onOneServer() ->runInBackground(); + // Reconcile GitHub App installations in case we missed any installation webhooks + $schedule->command('github:sync-installations') + ->dailyAt('09:30') + ->onOneServer() + ->runInBackground(); + // Remove Discord Max role for users with expired Max licenses $schedule->command('discord:remove-expired-roles') ->dailyAt('10:30') diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 888255ac3..71eead622 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Enums\GitHubAuthType; use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\RelationManagers; use App\Models\User; @@ -147,6 +148,18 @@ public static function table(Table $table): Table ->label('Developer') ->boolean() ->getStateUsing(fn (User $record) => $record->developerAccount !== null), + Tables\Columns\TextColumn::make('github_auth_type') + ->label('GitHub') + ->badge() + ->formatStateUsing(fn (GitHubAuthType $state) => $state->label()) + ->color(fn (GitHubAuthType $state) => $state === GitHubAuthType::App ? 'success' : 'warning') + ->placeholder('—') + ->toggleable(), + Tables\Columns\TextColumn::make('github_app_migration_notified_at') + ->label('GitHub App email sent') + ->dateTime() + ->placeholder('—') + ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->sortable(), @@ -155,7 +168,14 @@ public static function table(Table $table): Table ->sortable(), ]) ->filters([ - // + Tables\Filters\SelectFilter::make('github_auth_type') + ->label('GitHub connection') + ->options(collect(GitHubAuthType::cases())->mapWithKeys( + fn (GitHubAuthType $type) => [$type->value => $type->label()] + )), + Tables\Filters\Filter::make('plugin_authors_on_legacy_oauth') + ->label('Plugin authors still on the OAuth App') + ->query(fn ($query) => $query->where('github_auth_type', GitHubAuthType::OAuth)->has('plugins')), ]) ->actions([ Impersonate::make(), diff --git a/app/Http/Controllers/GitHubAppWebhookController.php b/app/Http/Controllers/GitHubAppWebhookController.php index ffd74e34e..9f9c3a036 100644 --- a/app/Http/Controllers/GitHubAppWebhookController.php +++ b/app/Http/Controllers/GitHubAppWebhookController.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers; +use App\Jobs\SyncPluginReleases; use App\Models\GitHubInstallation; +use App\Models\Plugin; use App\Models\User; use App\Services\GitHubUserService; +use App\Services\PluginSyncService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; @@ -25,6 +28,7 @@ public function __invoke(Request $request): JsonResponse return match ($event) { 'installation' => $this->handleInstallation($payload), 'installation_repositories' => $this->handleInstallationRepositories($payload), + 'push', 'release' => $this->handleRepositoryEvent($event, $payload), default => response()->json(['status' => 'ignored']), }; } @@ -176,4 +180,34 @@ protected function handleInstallationRepositories(array $payload): JsonResponse return response()->json(['status' => 'updated']); } + + /** + * Push and release events arrive here for every repository the app is installed on, so plugins + * the app covers stay in sync without a per-repository webhook. + */ + protected function handleRepositoryEvent(string $event, array $payload): JsonResponse + { + $fullName = $payload['repository']['full_name'] ?? null; + + if (! $fullName) { + return response()->json(['error' => 'Missing repository'], 400); + } + + $plugins = Plugin::query() + ->where('repository_url', "https://github.com/{$fullName}") + ->get() + ->filter(fn (Plugin $plugin): bool => $plugin->isActive()); + + $syncService = app(PluginSyncService::class); + + foreach ($plugins as $plugin) { + $syncService->sync($plugin); + + if ($event === 'release') { + dispatch(new SyncPluginReleases($plugin)); + } + } + + return response()->json(['status' => 'synced', 'plugins' => $plugins->count()]); + } } diff --git a/app/Http/Controllers/GitHubIntegrationController.php b/app/Http/Controllers/GitHubIntegrationController.php index 335a6b467..7891c5fee 100644 --- a/app/Http/Controllers/GitHubIntegrationController.php +++ b/app/Http/Controllers/GitHubIntegrationController.php @@ -14,7 +14,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Laravel\Socialite\Facades\Socialite; @@ -84,11 +83,10 @@ public function handleCallback(): RedirectResponse protected function handleLinkAccount($githubUser, GitHubAuthType $authType): RedirectResponse { $user = Auth::user(); - $user->update([ + + $this->saveGitHubCredentials($user, $githubUser, $authType, [ 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, - 'github_token' => encrypt($githubUser->token), - 'github_auth_type' => $authType, ]); $returnUrl = session()->pull('github_return_url'); @@ -116,10 +114,7 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR $user = User::where('github_id', $githubUser->id)->first(); if ($user) { - $user->update([ - 'github_token' => encrypt($githubUser->token), - 'github_auth_type' => $authType, - ]); + $this->saveGitHubCredentials($user, $githubUser, $authType); Auth::login($user, remember: true); @@ -129,11 +124,9 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR $user = User::where('email', $githubUser->email)->first(); if ($user) { - $user->update([ + $this->saveGitHubCredentials($user, $githubUser, $authType, [ 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, - 'github_token' => encrypt($githubUser->token), - 'github_auth_type' => $authType, ]); Auth::login($user, remember: true); @@ -146,8 +139,7 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR 'email' => $githubUser->email, 'github_id' => $githubUser->id, 'github_username' => $githubUser->nickname, - 'github_token' => encrypt($githubUser->token), - 'github_auth_type' => $authType, + ...$this->credentialAttributes($githubUser, $authType), 'password' => bcrypt(Str::random(24)), 'email_verified_at' => now(), ]); @@ -158,6 +150,38 @@ protected function handleLogin($githubUser, GitHubAuthType $authType): RedirectR ->with('success', 'Account created successfully!'); } + /** + * @return array + */ + protected function credentialAttributes($githubUser, GitHubAuthType $authType): array + { + return [ + 'github_token' => encrypt($githubUser->token), + 'github_auth_type' => $authType, + 'github_refresh_token' => $githubUser->refreshToken ? encrypt($githubUser->refreshToken) : null, + 'github_token_expires_at' => $githubUser->expiresIn ? now()->addSeconds($githubUser->expiresIn) : null, + ]; + } + + /** + * Save the user's new GitHub credentials. When they're moving from the legacy OAuth App to the + * GitHub App, their old authorization is revoked so its broad repo access stops working. + * + * @param array $attributes + */ + protected function saveGitHubCredentials(User $user, $githubUser, GitHubAuthType $authType, array $attributes = []): void + { + $legacyToken = $user->isUsingLegacyOAuth() && $authType === GitHubAuthType::App + ? $user->getGitHubToken() + : null; + + $user->update([...$attributes, ...$this->credentialAttributes($githubUser, $authType)]); + + if ($legacyToken) { + GitHubOAuth::make()->revokeOAuthGrant($legacyToken); + } + } + /** * Send plugin authors whose repositories the GitHub App can't reach to the installation * page, so signing in with the app never silently cuts off their plugin syncing. @@ -178,7 +202,7 @@ protected function redirectAfterLogin(User $user, GitHubAuthType $authType, stri public function handleSetup(Request $request): RedirectResponse { - $installationId = $request->query('installation_id'); + $installationId = (int) $request->query('installation_id'); if (! $installationId) { return to_route('customer.integrations') @@ -186,46 +210,29 @@ public function handleSetup(Request $request): RedirectResponse } $user = Auth::user(); + $appService = app(GitHubAppService::class); + $installation = GitHubInstallation::where('installation_id', $installationId)->first(); - // Record the installation if the webhook hasn't already - $existing = GitHubInstallation::where('installation_id', $installationId)->first(); - - if (! $existing) { - // Fetch installation details from GitHub - try { - $appService = app(GitHubAppService::class); - $jwt = $appService->generateJwt(); - - $response = Http::withHeaders([ - 'Authorization' => "Bearer {$jwt}", - 'Accept' => 'application/vnd.github+json', - ])->get("https://api.github.com/app/installations/{$installationId}"); - - if ($response->successful()) { - $data = $response->json(); - - $user->githubInstallations()->create([ - 'installation_id' => $installationId, - 'account_login' => $data['account']['login'] ?? 'unknown', - 'account_type' => $data['account']['type'] ?? 'User', - 'account_id' => $data['account']['id'] ?? null, - 'selection_type' => $data['repository_selection'] ?? 'all', - ]); - } - } catch (\Exception $e) { - Log::warning('Failed to fetch GitHub App installation details', [ - 'installation_id' => $installationId, - 'error' => $e->getMessage(), - ]); - - // Still create a basic record so the user isn't stuck - $user->githubInstallations()->create([ - 'installation_id' => $installationId, - 'account_login' => $user->github_username ?? 'unknown', - 'account_type' => 'User', - 'selection_type' => 'all', - ]); + if ($installation && $installation->user_id !== $user->id) { + return to_route('customer.integrations') + ->with('error', 'That GitHub App installation is already linked to another NativePHP account.'); + } + + if (! $installation) { + if (! $user->isUsingGitHubApp() || ! $appService->userCanAccessInstallation($user, $installationId)) { + return to_route('customer.integrations') + ->with('error', "We couldn't confirm that GitHub App installation belongs to your GitHub account. Please connect GitHub and try again."); } + + $installation = $user->githubInstallations()->create([ + 'installation_id' => $installationId, + 'account_login' => $user->github_username ?? 'unknown', + ]); + } + + if (! $appService->syncInstallation($installation) && ! $installation->exists) { + return to_route('customer.integrations') + ->with('error', 'That GitHub App installation no longer exists.'); } GitHubUserService::for($user)->clearRepositoryCache(); @@ -316,6 +323,8 @@ public function disconnect(): RedirectResponse 'github_username' => null, 'github_token' => null, 'github_auth_type' => null, + 'github_refresh_token' => null, + 'github_token_expires_at' => null, 'mobile_repo_access_granted_at' => null, 'claude_plugins_repo_access_granted_at' => null, ]); diff --git a/app/Livewire/Customer/Plugins/Show.php b/app/Livewire/Customer/Plugins/Show.php index 1fd425d4a..22b3a6ad2 100644 --- a/app/Livewire/Customer/Plugins/Show.php +++ b/app/Livewire/Customer/Plugins/Show.php @@ -130,8 +130,12 @@ public function runPreflightChecks(): void $this->plugin->generateWebhookSecret(); } - // Verify or install webhook - if ($repoInfo && $user->hasGitHubToken()) { + // The GitHub App delivers push and release events for repos it covers, so no per-repo hook is needed + if ($this->plugin->isReachableViaGitHubApp()) { + if (! $this->plugin->webhook_installed) { + $this->plugin->update(['webhook_installed' => true]); + } + } elseif ($repoInfo && $user->hasGitHubToken()) { $githubService = GitHubUserService::for($user); $webhookUrl = $this->plugin->getWebhookUrl(); @@ -233,6 +237,15 @@ public function retryWebhook(): void $user = auth()->user(); $repoInfo = $this->plugin->getRepositoryOwnerAndName(); + if ($this->plugin->isReachableViaGitHubApp()) { + $this->plugin->update(['webhook_installed' => true]); + $this->plugin->refresh(); + + Flux::toast(variant: 'success', text: 'The NativePHP GitHub App keeps this plugin in sync, so no webhook is needed.'); + + return; + } + if (! $repoInfo || ! $user->hasGitHubToken()) { Flux::toast(variant: 'danger', text: 'Unable to register webhook automatically. Please ensure your GitHub account is connected and the repository URL is valid.'); diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 50db2e4af..64cc28518 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -15,6 +15,7 @@ use App\Notifications\PluginDeveloperReplied; use App\Notifications\PluginMessageReceived; use App\Notifications\PluginRejected; +use App\Services\GitHubAppService; use App\Services\OgImageService; use App\Services\PluginSyncService; use App\Support\PluginReadme; @@ -675,6 +676,21 @@ public function generateWebhookSecret(): string return $secret; } + /** + * Whether the owner's GitHub App installation covers this repository, in which case the app + * delivers push and release events for it and no per-repository webhook is needed. + */ + public function isReachableViaGitHubApp(): bool + { + $repo = $this->getRepositoryOwnerAndName(); + + if (! $repo || ! $this->user?->isUsingGitHubApp()) { + return false; + } + + return app(GitHubAppService::class)->findInstallationForRepo($this->user, $repo['owner'], $repo['repo']) !== null; + } + public function getRepositoryOwnerAndName(): ?array { if (! $this->repository_url) { diff --git a/app/Models/User.php b/app/Models/User.php index f6ac6fe4f..ba4679feb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,7 @@ use App\Enums\PriceTier; use App\Enums\Subscription; use App\Enums\TeamUserStatus; +use App\Services\GitHubAppService; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasName; use Filament\Panel; @@ -51,6 +52,7 @@ public function withAccessToken(HasAbilities|ScopeAuthorizable|null $accessToken 'password', 'remember_token', 'github_token', + 'github_refresh_token', ]; public function getFilamentName(): string @@ -514,6 +516,14 @@ public function getGitHubToken(): ?string return null; } + if ($this->isUsingLegacyOAuth() && app(GitHubAppService::class)->legacyOAuthHasBeenRetired()) { + return null; + } + + if ($this->github_token_expires_at?->isBefore(now()->addMinute())) { + return app(GitHubAppService::class)->refreshUserToken($this); + } + try { return decrypt($this->github_token); } catch (\Exception) { @@ -521,6 +531,19 @@ public function getGitHubToken(): ?string } } + public function getGitHubRefreshToken(): ?string + { + if (! $this->github_refresh_token) { + return null; + } + + try { + return decrypt($this->github_refresh_token); + } catch (\Exception) { + return null; + } + } + public function hasGitHubToken(): bool { return $this->getGitHubToken() !== null; @@ -549,6 +572,23 @@ public function needsGitHubAppMigration(): bool return $this->isUsingLegacyOAuth(); } + /** + * The "owner/repo" name of each of the user's plugin repositories. + * + * @return Collection + */ + public function pluginRepositoryNames(): Collection + { + return $this->plugins() + ->whereNotNull('repository_url') + ->get() + ->map(fn (Plugin $plugin): ?array => $plugin->getRepositoryOwnerAndName()) + ->filter() + ->map(fn (array $repo): string => "{$repo['owner']}/{$repo['repo']}") + ->unique() + ->values(); + } + /** * Whether the user has signed in with the GitHub App but hasn't installed it anywhere yet. */ @@ -670,6 +710,8 @@ protected function casts(): array 'discord_role_granted_at' => 'datetime', 'discord_early_adopter_role_granted_at' => 'datetime', 'github_auth_type' => GitHubAuthType::class, + 'github_token_expires_at' => 'datetime', + 'github_app_migration_notified_at' => 'datetime', ]; } } diff --git a/app/Notifications/GitHubAppMigrationRequired.php b/app/Notifications/GitHubAppMigrationRequired.php new file mode 100644 index 000000000..717fa8f68 --- /dev/null +++ b/app/Notifications/GitHubAppMigrationRequired.php @@ -0,0 +1,39 @@ +name ?? null; + $firstName = $name ? explode(' ', $name)[0] : null; + $cutoffDate = app(GitHubAppService::class)->legacyOAuthCutoffDate(); + $deadline = $cutoffDate ? $cutoffDate->format('j F Y') : 'we switch off the old connection'; + + return (new MailMessage) + ->subject('A security improvement to how NativePHP connects to GitHub') + ->greeting($firstName ? "Hi {$firstName}," : 'Hi there,') + ->line('We\'re moving away from GitHub OAuth on nativephp.com, which gave us broad access to your repositories, to a GitHub App that gives you fine-grained control over what we can see.') + ->line('## How does this affect you?') + ->line('**If you use "Login with GitHub"**, you don\'t need to do anything. The difference is that the credentials we use no longer grant us access to any of your repositories. We simply use GitHub to confirm your identity.') + ->line("**If you are a plugin author**, you need to connect our new GitHub App from your dashboard. If you don't do it before {$deadline}, we won't be able to keep your plugins up to date and may remove them from the Marketplace.") + ->action('Connect the GitHub App', route('customer.integrations')) + ->line('This does not affect [Bifrost](https://bifrost.nativephp.com), which already uses its own GitHub App with fine-grained access to only the repositories you explicitly allow.') + ->line('Bifrost is the fastest way to ship native apps with AI and we have 30% off annual plans until the end of the year!') + ->salutation("Cheers,\n\nThe NativePHP Team"); + } +} diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php index 9be0d214b..01fe9deaf 100644 --- a/app/Services/GitHubAppService.php +++ b/app/Services/GitHubAppService.php @@ -4,6 +4,7 @@ use App\Models\GitHubInstallation; use App\Models\User; +use Carbon\CarbonImmutable; use Firebase\JWT\JWT; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -21,6 +22,24 @@ public function installationUrl(): ?string return $slug ? "https://github.com/apps/{$slug}/installations/new" : null; } + /** + * When the legacy OAuth App stops working, if a date has been set. + */ + public function legacyOAuthCutoffDate(): ?CarbonImmutable + { + $date = config('services.github.legacy_oauth_cutoff_date'); + + return $date ? CarbonImmutable::parse($date) : null; + } + + /** + * Whether the legacy OAuth App's cutoff date has passed, after which its tokens are no longer used. + */ + public function legacyOAuthHasBeenRetired(): bool + { + return $this->legacyOAuthCutoffDate()?->isPast() ?? false; + } + public function generateJwt(): string { $privateKeyPath = config('services.github_app.private_key_path'); @@ -98,4 +117,191 @@ public function findInstallationForRepo(User $user, string $owner, string $repo) ->get() ->first(fn (GitHubInstallation $installation) => $installation->hasAccessToRepo($owner, $repo)); } + + /** + * Whether the installation is one the user can see on GitHub. GitHub's post-install redirect + * passes the installation ID in the query string, so it can't be trusted on its own. + */ + public function userCanAccessInstallation(User $user, int $installationId): bool + { + $token = $user->getGitHubToken(); + + if (! $token) { + return false; + } + + $page = 1; + + do { + $response = Http::withToken($token) + ->accept('application/vnd.github+json') + ->get('https://api.github.com/user/installations', [ + 'per_page' => 100, + 'page' => $page, + ]); + + if ($response->failed()) { + Log::warning('[GitHubApp] Failed to list user installations', [ + 'user_id' => $user->id, + 'status' => $response->status(), + ]); + + return false; + } + + $installations = collect($response->json('installations', [])); + + if ($installations->contains('id', $installationId)) { + return true; + } + + $page++; + } while ($installations->count() === 100 && $page <= 10); + + return false; + } + + /** + * Refresh an installation's account details and repository list from GitHub. An installation + * GitHub no longer knows about is deleted. + */ + public function syncInstallation(GitHubInstallation $installation): bool + { + try { + $response = Http::withToken($this->generateJwt()) + ->accept('application/vnd.github+json') + ->get("https://api.github.com/app/installations/{$installation->installation_id}"); + } catch (\Exception $e) { + Log::warning('[GitHubApp] Exception syncing installation', [ + 'installation_id' => $installation->installation_id, + 'error' => $e->getMessage(), + ]); + + return false; + } + + if ($response->notFound()) { + $installation->delete(); + + return false; + } + + if ($response->failed()) { + Log::warning('[GitHubApp] Failed to sync installation', [ + 'installation_id' => $installation->installation_id, + 'status' => $response->status(), + ]); + + return false; + } + + $data = $response->json(); + $selectionType = $data['repository_selection'] ?? 'all'; + + $installation->update([ + 'account_login' => $data['account']['login'] ?? $installation->account_login, + 'account_type' => $data['account']['type'] ?? $installation->account_type, + 'account_id' => $data['account']['id'] ?? $installation->account_id, + 'selection_type' => $selectionType, + 'suspended_at' => $data['suspended_at'] ?? null, + 'repository_selection' => $selectionType === 'selected' + ? ($this->fetchInstallationRepositories($installation) ?? $installation->repository_selection) + : null, + ]); + + return true; + } + + /** + * @return array|null Full names of the repositories the installation can access, or null if they couldn't be fetched. + */ + protected function fetchInstallationRepositories(GitHubInstallation $installation): ?array + { + $token = $this->getInstallationToken($installation); + + if (! $token) { + return null; + } + + $repositories = []; + $page = 1; + + do { + $response = Http::withToken($token) + ->accept('application/vnd.github+json') + ->get('https://api.github.com/installation/repositories', [ + 'per_page' => 100, + 'page' => $page, + ]); + + if ($response->failed()) { + return null; + } + + $pageRepositories = collect($response->json('repositories', []))->pluck('full_name'); + $repositories = [...$repositories, ...$pageRepositories->all()]; + $page++; + } while ($pageRepositories->count() === 100 && $page <= 10); + + return $repositories; + } + + /** + * Swap an expired GitHub App user token for a new one. Clears the stored tokens if GitHub + * rejects the refresh token, so the user is asked to reconnect. + */ + public function refreshUserToken(User $user): ?string + { + $refreshToken = $user->getGitHubRefreshToken(); + + if (! $refreshToken) { + return null; + } + + try { + $response = Http::asForm() + ->acceptJson() + ->post('https://github.com/login/oauth/access_token', [ + 'client_id' => config('services.github_app.client_id'), + 'client_secret' => config('services.github_app.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + ]); + } catch (\Exception $e) { + Log::warning('[GitHubApp] Exception refreshing user token', [ + 'user_id' => $user->id, + 'error' => $e->getMessage(), + ]); + + return null; + } + + $accessToken = $response->json('access_token'); + + if ($response->failed() || ! $accessToken) { + Log::warning('[GitHubApp] Failed to refresh user token', [ + 'user_id' => $user->id, + 'status' => $response->status(), + 'error' => $response->json('error'), + ]); + + if ($response->json('error') === 'bad_refresh_token') { + $user->update([ + 'github_token' => null, + 'github_refresh_token' => null, + 'github_token_expires_at' => null, + ]); + } + + return null; + } + + $user->update([ + 'github_token' => encrypt($accessToken), + 'github_refresh_token' => encrypt($response->json('refresh_token') ?? $refreshToken), + 'github_token_expires_at' => $response->json('expires_in') ? now()->addSeconds($response->json('expires_in')) : null, + ]); + + return $accessToken; + } } diff --git a/app/Support/GitHubOAuth.php b/app/Support/GitHubOAuth.php index aea8e9702..df280c66d 100644 --- a/app/Support/GitHubOAuth.php +++ b/app/Support/GitHubOAuth.php @@ -22,6 +22,43 @@ public static function make(): static return new static(config('services.github.token', '')); } + /** + * Revoke a user's authorization of the legacy OAuth App, which invalidates every token it issued them. + */ + public function revokeOAuthGrant(string $accessToken): bool + { + $clientId = config('services.github.client_id'); + $clientSecret = config('services.github.client_secret'); + + if (! $clientId || ! $clientSecret) { + return false; + } + + try { + $response = Http::withBasicAuth($clientId, $clientSecret) + ->accept('application/vnd.github+json') + ->delete("https://api.github.com/applications/{$clientId}/grant", [ + 'access_token' => $accessToken, + ]); + } catch (\Exception $e) { + Log::warning('Failed to revoke legacy GitHub OAuth grant', ['error' => $e->getMessage()]); + + return false; + } + + // 404 means the grant is already gone + if ($response->failed() && ! $response->notFound()) { + Log::warning('Failed to revoke legacy GitHub OAuth grant', [ + 'status' => $response->status(), + 'response' => $response->json(), + ]); + + return false; + } + + return true; + } + /** * Invite a user to a repository with read-only access. */ diff --git a/config/services.php b/config/services.php index 22f34bccf..919b690d7 100644 --- a/config/services.php +++ b/config/services.php @@ -65,6 +65,8 @@ 'client_secret' => env('GITHUB_CLIENT_SECRET'), 'redirect' => env('APP_URL').'/auth/github/callback', 'token' => env('GITHUB_TOKEN'), + // The date the legacy OAuth App stops working, shown to people who haven't moved to the GitHub App yet + 'legacy_oauth_cutoff_date' => env('GITHUB_LEGACY_OAUTH_CUTOFF_DATE'), ], 'github_app' => [ diff --git a/database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php b/database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php new file mode 100644 index 000000000..ba6793398 --- /dev/null +++ b/database/migrations/2026_09_24_102741_add_github_app_token_and_migration_fields_to_users_table.php @@ -0,0 +1,30 @@ +text('github_refresh_token')->nullable()->after('github_auth_type'); + $table->timestamp('github_token_expires_at')->nullable()->after('github_refresh_token'); + $table->timestamp('github_app_migration_notified_at')->nullable()->after('github_token_expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['github_refresh_token', 'github_token_expires_at', 'github_app_migration_notified_at']); + }); + } +}; diff --git a/resources/views/components/github-migration-banner.blade.php b/resources/views/components/github-migration-banner.blade.php index f21ce2081..d41279bb9 100644 --- a/resources/views/components/github-migration-banner.blade.php +++ b/resources/views/components/github-migration-banner.blade.php @@ -4,68 +4,97 @@ $user = auth()->user(); $needsMigration = $user->needsGitHubAppMigration(); $missingAccess = $needsMigration ? collect() : $user->pluginsMissingGitHubAppAccess(); - $pluginRepos = ($needsMigration ? $user->plugins()->whereNotNull('repository_url')->get() : $missingAccess) - ->map(fn ($plugin) => $plugin->getRepositoryOwnerAndName()) - ->filter() - ->map(fn (array $repo) => "{$repo['owner']}/{$repo['repo']}") - ->unique() - ->values(); - $installUrl = app(\App\Services\GitHubAppService::class)->installationUrl(); + $pluginRepos = $needsMigration + ? $user->pluginRepositoryNames() + : $missingAccess + ->map(fn ($plugin) => $plugin->getRepositoryOwnerAndName()) + ->filter() + ->map(fn (array $repo) => "{$repo['owner']}/{$repo['repo']}") + ->unique() + ->values(); + $appService = app(\App\Services\GitHubAppService::class); + $installUrl = $appService->installationUrl(); + $cutoffDate = $appService->legacyOAuthCutoffDate(); + $deadline = $cutoffDate ? $cutoffDate->format('j F Y') : 'we switch off the old connection'; + $urgent = ! $needsMigration || $blocking || $pluginRepos->isNotEmpty(); @endphp @if($needsMigration || $missingAccess->isNotEmpty()) -
+
$urgent, + 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20' => ! $urgent, + ])>
- + $urgent, 'text-blue-400' => ! $urgent]) viewBox="0 0 20 20" fill="currentColor">
-

- {{ $needsMigration ? 'GitHub Connection Upgrade Required' : 'GitHub App Needs Access to Your Plugins' }} +

$urgent, 'text-blue-800 dark:text-blue-200' => ! $urgent])> + @if(! $needsMigration) + GitHub App Needs Access to Your Plugins + @elseif($urgent) + GitHub Connection Upgrade Required + @else + We've Improved Our GitHub Connection + @endif

-
+
$urgent, 'text-blue-700 dark:text-blue-300' => ! $urgent])> @if($needsMigration)

- We've upgraded our GitHub integration to use a GitHub App with fine-grained permissions. - Your current connection uses an older OAuth App that requests broader access than needed. + We're moving away from GitHub OAuth, which gave us broad access to your repositories, + to a GitHub App that gives you fine-grained control over what we can see.

+ + @if($pluginRepos->isNotEmpty()) +

+ As a plugin author, you need to connect our new GitHub App. If you don't do it before {{ $deadline }}, + we won't be able to keep your plugins up to date and may remove them from the Marketplace. +

+

If you choose "Only select repositories", include each of these:

+ @elseif($blocking) +

You need to connect the GitHub App before you can create a plugin.

+ @else +

+ You don't need to do anything. "Login with GitHub" keeps working, and we only use it to confirm your identity. + You can reconnect now if you'd like to switch straight away. +

+ @endif @else

We can't reach some of your plugin repositories, so they won't sync new releases. Install the NativePHP GitHub App on the accounts that own them, or add them to an existing installation.

+

Grant access to:

@endif @if($pluginRepos->isNotEmpty()) -

- @if($needsMigration) - When you upgrade, make sure the GitHub App can access these repositories. If you choose "Only select repositories", include each of them: - @else - Grant access to: - @endif -

    @foreach($pluginRepos as $repo)
  • {{ $repo }}
  • @endforeach
@endif - - @if($blocking) -

- You must upgrade your GitHub connection before you can submit or manage plugins. -

- @endif
@if($needsMigration || $installUrl) @endif diff --git a/resources/views/livewire/customer/integrations.blade.php b/resources/views/livewire/customer/integrations.blade.php index 28fd2135d..0eb2a98b5 100644 --- a/resources/views/livewire/customer/integrations.blade.php +++ b/resources/views/livewire/customer/integrations.blade.php @@ -86,7 +86,10 @@
  • Any access to the private nativephp/mobile and nativephp/claude-code repositories will be revoked.
  • Your GitHub repositories will no longer be available when submitting or managing plugins.
  • -
  • You can reconnect at any time. When re-authorizing, be sure to grant access to any organizations whose repositories you need.
  • +
  • You can reconnect at any time. When you do, give the NativePHP GitHub App access to the repositories you need.
  • + @if (auth()->user()->isUsingGitHubApp()) +
  • The NativePHP GitHub App stays installed on your GitHub accounts until you uninstall it from your GitHub settings.
  • + @endif
diff --git a/tests/Concerns/InteractsWithGitHubApp.php b/tests/Concerns/InteractsWithGitHubApp.php new file mode 100644 index 000000000..0ab4932d4 --- /dev/null +++ b/tests/Concerns/InteractsWithGitHubApp.php @@ -0,0 +1,34 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $privateKey); + + $path = tempnam(sys_get_temp_dir(), 'github-app-key'); + file_put_contents($path, $privateKey); + + $keys = [$path, openssl_pkey_get_details($key)['key']]; + } + + config([ + 'services.github_app.app_id' => '12345', + 'services.github_app.client_id' => 'Iv1.testclient', + 'services.github_app.client_secret' => 'test-app-secret', + 'services.github_app.private_key_path' => $keys[0], + 'services.github_app.slug' => 'nativephp-test', + ]); + + return $keys[1]; + } +} diff --git a/tests/Feature/GitHubAppInstallationPromptTest.php b/tests/Feature/GitHubAppInstallationPromptTest.php index b6daced6e..ec345ff97 100644 --- a/tests/Feature/GitHubAppInstallationPromptTest.php +++ b/tests/Feature/GitHubAppInstallationPromptTest.php @@ -5,6 +5,7 @@ use App\Enums\GitHubAuthType; use App\Features\ShowAuthButtons; use App\Features\ShowPlugins; +use App\Livewire\GitHubAppStatus; use App\Models\GitHubInstallation; use App\Models\Plugin; use App\Models\User; @@ -15,6 +16,7 @@ use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Two\GithubProvider; use Laravel\Socialite\Two\User as SocialiteUser; +use Livewire\Livewire; use Mockery; use Tests\TestCase; @@ -256,4 +258,46 @@ public function test_setup_callback_clears_the_cached_repository_list(): void $this->assertFalse(Cache::has("github_repos_{$user->id}")); } + + public function test_integrations_page_shows_installations_and_plugin_repo_coverage(): void + { + $user = User::factory()->withGitHubApp()->create(); + $this->pluginFor($user, 'acme/covered-plugin'); + $this->pluginFor($user, 'other-org/uncovered-plugin'); + + $installation = GitHubInstallation::factory()->forOrganization()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + Livewire::actingAs($user) + ->test(GitHubAppStatus::class) + ->assertSee('acme') + ->assertSee('All repositories') + ->assertSee("https://github.com/settings/installations/{$installation->installation_id}") + ->assertSee('acme/covered-plugin') + ->assertSee('other-org/uncovered-plugin') + ->assertSeeInOrder(['other-org/uncovered-plugin', 'Not accessible']); + } + + public function test_integrations_page_prompts_to_install_when_there_are_no_installations(): void + { + $user = User::factory()->withGitHubApp()->create(); + + Livewire::actingAs($user) + ->test(GitHubAppStatus::class) + ->assertSee('No GitHub App installations found') + ->assertSee(self::INSTALL_URL); + } + + public function test_disconnect_modal_explains_the_app_stays_installed(): void + { + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user) + ->get(route('customer.integrations')) + ->assertSee('give the NativePHP GitHub App access to the repositories you need') + ->assertSee('stays installed on your GitHub accounts'); + } } diff --git a/tests/Feature/GitHubAppMigrationNoticeTest.php b/tests/Feature/GitHubAppMigrationNoticeTest.php new file mode 100644 index 000000000..0d887f4a6 --- /dev/null +++ b/tests/Feature/GitHubAppMigrationNoticeTest.php @@ -0,0 +1,145 @@ + Http::response([], 404)]); + } + + public function test_dry_run_does_not_send_anything(): void + { + Notification::fake(); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:send-app-migration-notice --dry-run') + ->expectsOutputToContain("Would send to: {$user->email}") + ->assertSuccessful(); + + Notification::assertNothingSent(); + $this->assertNull($user->fresh()->github_app_migration_notified_at); + } + + public function test_notice_goes_to_verified_legacy_users_once(): void + { + Notification::fake(); + config(['services.github.legacy_oauth_cutoff_date' => '2026-12-31']); + + $legacy = User::factory()->withLegacyGitHub()->create(); + $unverified = User::factory()->withLegacyGitHub()->unverified()->create(); + $appUser = User::factory()->withGitHubApp()->create(); + $notConnected = User::factory()->create(); + + $this->artisan('github:send-app-migration-notice')->assertSuccessful(); + $this->artisan('github:send-app-migration-notice')->assertSuccessful(); + + Notification::assertSentToTimes($legacy, GitHubAppMigrationRequired::class, 1); + Notification::assertNotSentTo([$unverified, $appUser, $notConnected], GitHubAppMigrationRequired::class); + $this->assertNotNull($legacy->fresh()->github_app_migration_notified_at); + } + + public function test_real_send_refuses_to_run_without_a_cutoff_date(): void + { + Notification::fake(); + + User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:send-app-migration-notice') + ->expectsOutputToContain('GITHUB_LEGACY_OAUTH_CUTOFF_DATE') + ->assertFailed(); + + Notification::assertNothingSent(); + } + + public function test_preview_sends_a_single_copy_to_the_given_address(): void + { + Notification::fake(); + + $legacy = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:send-app-migration-notice --preview=me@example.com') + ->expectsOutputToContain('Sent a preview to me@example.com') + ->assertSuccessful(); + + Notification::assertSentOnDemand( + GitHubAppMigrationRequired::class, + fn ($notification, array $channels, $notifiable) => $notifiable->routes['mail'] === 'me@example.com' + ); + Notification::assertNotSentTo($legacy, GitHubAppMigrationRequired::class); + $this->assertNull($legacy->fresh()->github_app_migration_notified_at); + } + + public function test_email_explains_both_cases_and_gives_the_deadline(): void + { + config(['services.github.legacy_oauth_cutoff_date' => '2026-12-31']); + + $user = User::factory()->withLegacyGitHub()->create(['name' => 'Priya Patel']); + + $html = (string) (new GitHubAppMigrationRequired)->toMail($user)->render(); + + $this->assertStringContainsString('Hi Priya,', $html); + $this->assertStringContainsString('How does this affect you?', $html); + $this->assertStringContainsString('Login with GitHub', $html); + $this->assertStringContainsString('need to do anything', $html); + $this->assertStringContainsString('If you are a plugin author', $html); + $this->assertStringContainsString('before 31 December 2026', $html); + $this->assertStringContainsString('may remove them from the Marketplace', $html); + $this->assertStringContainsString(route('customer.integrations'), $html); + $this->assertStringContainsString('moving away from GitHub OAuth on nativephp.com', $html); + $this->assertStringContainsString('href="https://bifrost.nativephp.com"', $html); + $this->assertStringContainsString('30% off annual plans', $html); + $this->assertLessThan(strpos($html, 'This does not affect'), strpos($html, 'Connect the GitHub App')); + } + + public function test_email_falls_back_to_generic_wording_without_a_cutoff_date(): void + { + $user = User::factory()->withLegacyGitHub()->create(); + + $html = (string) (new GitHubAppMigrationRequired)->toMail($user)->render(); + + $this->assertStringContainsString('before we switch off the old connection', $html); + } + + public function test_admins_can_filter_for_plugin_authors_still_on_the_oauth_app(): void + { + $admin = User::factory()->create(['email' => 'admin@test.com']); + config(['filament.users' => ['admin@test.com']]); + + $legacyAuthor = User::factory()->withLegacyGitHub()->create(); + Plugin::factory()->create(['user_id' => $legacyAuthor->id]); + + $legacyWithoutPlugins = User::factory()->withLegacyGitHub()->create(); + + $appAuthor = User::factory()->withGitHubApp()->create(); + Plugin::factory()->create(['user_id' => $appAuthor->id]); + + Livewire::actingAs($admin) + ->test(ListUsers::class) + ->filterTable('plugin_authors_on_legacy_oauth') + ->assertCanSeeTableRecords([$legacyAuthor]) + ->assertCanNotSeeTableRecords([$legacyWithoutPlugins, $appAuthor]); + } +} diff --git a/tests/Feature/GitHubAppRepositoryEventsTest.php b/tests/Feature/GitHubAppRepositoryEventsTest.php new file mode 100644 index 000000000..2f1208d36 --- /dev/null +++ b/tests/Feature/GitHubAppRepositoryEventsTest.php @@ -0,0 +1,171 @@ + self::WEBHOOK_SECRET]); + } + + private function sendAppWebhook(string $event, array $payload): TestResponse + { + $body = json_encode($payload); + + return $this->call('POST', '/webhooks/github-app', [], [], [], [ + 'HTTP_X_GITHUB_EVENT' => $event, + 'HTTP_X_HUB_SIGNATURE_256' => 'sha256='.hash_hmac('sha256', $body, self::WEBHOOK_SECRET), + 'CONTENT_TYPE' => 'application/json', + ], $body); + } + + private function coveredPlugin(array $attributes = []): Plugin + { + $user = User::factory()->withGitHubApp()->create(); + + GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'account_login' => 'acme', + 'selection_type' => 'all', + ]); + + return Plugin::factory()->create([ + 'user_id' => $user->id, + 'repository_url' => 'https://github.com/acme/camera-plugin', + ...$attributes, + ]); + } + + public function test_push_event_from_the_app_syncs_the_matching_plugin(): void + { + Bus::fake([SyncPluginReleases::class]); + $plugin = $this->coveredPlugin(); + + $this->mock(PluginSyncService::class, function (MockInterface $mock) use ($plugin): void { + $mock->shouldReceive('sync')->once()->withArgs(fn (Plugin $synced) => $synced->is($plugin))->andReturn(true); + }); + + $this->sendAppWebhook('push', ['repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk() + ->assertJson(['plugins' => 1]); + + Bus::assertNotDispatched(SyncPluginReleases::class); + } + + public function test_release_event_from_the_app_syncs_releases(): void + { + Bus::fake([SyncPluginReleases::class]); + $plugin = $this->coveredPlugin(); + + $this->mock(PluginSyncService::class, function (MockInterface $mock): void { + $mock->shouldReceive('sync')->once()->andReturn(true); + }); + + $this->sendAppWebhook('release', ['repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk(); + + Bus::assertDispatched(SyncPluginReleases::class, fn (SyncPluginReleases $job) => $job->plugin->is($plugin)); + } + + public function test_events_for_inactive_plugins_are_ignored(): void + { + $this->coveredPlugin(['is_active' => false]); + + $this->mock(PluginSyncService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('sync'); + }); + + $this->sendAppWebhook('push', ['repository' => ['full_name' => 'acme/camera-plugin']]) + ->assertOk() + ->assertJson(['plugins' => 0]); + } + + public function test_repository_events_need_a_valid_signature(): void + { + $this->coveredPlugin(); + + $this->postJson('/webhooks/github-app', ['repository' => ['full_name' => 'acme/camera-plugin']], [ + 'X-GitHub-Event' => 'push', + 'X-Hub-Signature-256' => 'sha256=invalid', + ])->assertForbidden(); + } + + public function test_preflight_checks_skip_the_repo_webhook_when_the_app_covers_the_repo(): void + { + Http::fake(['*' => Http::response([], 404)]); + + $plugin = $this->coveredPlugin(['webhook_installed' => false]); + $plugin->update(['status' => PluginStatus::Draft]); + + Livewire::actingAs($plugin->user) + ->test(Show::class, $plugin->routeParams()) + ->call('runPreflightChecks'); + + $this->assertTrue($plugin->fresh()->webhook_installed); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/hooks')); + } + + public function test_retrying_the_webhook_does_not_create_one_when_the_app_covers_the_repo(): void + { + Http::fake(['*' => Http::response([], 404)]); + + $plugin = $this->coveredPlugin(['webhook_installed' => false]); + + Livewire::actingAs($plugin->user) + ->test(Show::class, $plugin->routeParams()) + ->call('retryWebhook'); + + $this->assertTrue($plugin->fresh()->webhook_installed); + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/hooks')); + } + + public function test_plugins_the_app_cannot_reach_still_get_a_repo_webhook(): void + { + Http::fake(['*' => Http::response([], 404)]); + + $user = User::factory()->withGitHubApp()->create(); + $plugin = Plugin::factory()->create([ + 'user_id' => $user->id, + 'repository_url' => 'https://github.com/acme/camera-plugin', + 'webhook_installed' => false, + ]); + + $this->assertFalse($plugin->isReachableViaGitHubApp()); + + Livewire::actingAs($user) + ->test(Show::class, $plugin->routeParams()) + ->call('retryWebhook'); + + Http::assertSent(fn (Request $request) => $request->method() === 'POST' + && str_ends_with($request->url(), '/repos/acme/camera-plugin/hooks')); + } +} diff --git a/tests/Feature/GitHubAppSetupTest.php b/tests/Feature/GitHubAppSetupTest.php new file mode 100644 index 000000000..bcd8540bb --- /dev/null +++ b/tests/Feature/GitHubAppSetupTest.php @@ -0,0 +1,179 @@ +configureGitHubApp(); + } + + /** + * @param array $userInstallationIds + * @param array $repositories + */ + private function fakeGitHub(array $userInstallationIds, string $selection = 'selected', array $repositories = []): void + { + Http::fake([ + 'api.github.com/user/installations*' => Http::response([ + 'installations' => array_map(fn (int $id) => ['id' => $id], $userInstallationIds), + ]), + 'api.github.com/app/installations/555/access_tokens' => Http::response([ + 'token' => 'ghs_installation_token', + 'expires_at' => now()->addHour()->toIso8601String(), + ]), + 'api.github.com/app/installations/555' => Http::response([ + 'id' => 555, + 'account' => ['login' => 'acme', 'type' => 'Organization', 'id' => 99], + 'repository_selection' => $selection, + 'suspended_at' => null, + ]), + 'api.github.com/installation/repositories*' => Http::response([ + 'repositories' => array_map(fn (string $name) => ['full_name' => $name], $repositories), + ]), + ]); + } + + public function test_setup_records_an_installation_the_user_can_see_on_github(): void + { + $this->fakeGitHub([555], 'selected', ['acme/one', 'acme/two']); + + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertRedirect(route('customer.integrations')) + ->assertSessionHas('success'); + + $installation = $user->githubInstallations()->sole(); + + $this->assertSame(555, (int) $installation->installation_id); + $this->assertSame('acme', $installation->account_login); + $this->assertSame('Organization', $installation->account_type); + $this->assertSame('selected', $installation->selection_type); + $this->assertSame(['acme/one', 'acme/two'], $installation->repository_selection); + } + + public function test_setup_does_not_store_a_repository_list_when_all_repos_are_selected(): void + { + $this->fakeGitHub([555], 'all'); + + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user)->get(route('github.setup', ['installation_id' => 555])); + + $installation = $user->githubInstallations()->sole(); + + $this->assertSame('all', $installation->selection_type); + $this->assertNull($installation->repository_selection); + } + + public function test_setup_rejects_an_installation_the_user_cannot_see_on_github(): void + { + $this->fakeGitHub([111, 222]); + + $user = User::factory()->withGitHubApp()->create(); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertRedirect(route('customer.integrations')) + ->assertSessionHas('error'); + + $this->assertDatabaseCount('github_installations', 0); + } + + public function test_setup_rejects_users_still_on_the_legacy_oauth_app(): void + { + $this->fakeGitHub([555]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertSessionHas('error'); + + $this->assertDatabaseCount('github_installations', 0); + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/user/installations')); + } + + public function test_setup_will_not_hand_over_an_installation_linked_to_someone_else(): void + { + $this->fakeGitHub([555]); + + $owner = User::factory()->withGitHubApp()->create(); + GitHubInstallation::factory()->create([ + 'user_id' => $owner->id, + 'installation_id' => 555, + ]); + + $otherUser = User::factory()->withGitHubApp()->create(); + + $this->actingAs($otherUser) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertSessionHas('error'); + + $this->assertDatabaseHas('github_installations', [ + 'installation_id' => 555, + 'user_id' => $owner->id, + ]); + } + + public function test_setup_refreshes_an_installation_the_webhook_already_recorded(): void + { + $this->fakeGitHub([555], 'selected', ['acme/new-repo']); + + $user = User::factory()->withGitHubApp()->create(); + $installation = GitHubInstallation::factory()->selectedRepos([])->create([ + 'user_id' => $user->id, + 'installation_id' => 555, + 'account_login' => 'acme', + ]); + + $this->actingAs($user) + ->get(route('github.setup', ['installation_id' => 555])) + ->assertSessionHas('success'); + + $this->assertSame(['acme/new-repo'], $installation->fresh()->repository_selection); + } + + public function test_sync_command_updates_installations_and_removes_ones_github_no_longer_has(): void + { + $this->fakeGitHub([], 'selected', ['acme/one']); + Http::fake(['api.github.com/app/installations/777' => Http::response([], 404)]); + + $user = User::factory()->withGitHubApp()->create(); + $current = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 555, + 'selection_type' => 'all', + ]); + $removed = GitHubInstallation::factory()->create([ + 'user_id' => $user->id, + 'installation_id' => 777, + ]); + + $this->artisan('github:sync-installations') + ->expectsOutputToContain('Synced: 1, removed: 1, failed: 0') + ->assertSuccessful(); + + $this->assertSame(['acme/one'], $current->fresh()->repository_selection); + $this->assertModelMissing($removed); + } +} diff --git a/tests/Feature/GitHubAppUserTokenTest.php b/tests/Feature/GitHubAppUserTokenTest.php new file mode 100644 index 000000000..3f5941a92 --- /dev/null +++ b/tests/Feature/GitHubAppUserTokenTest.php @@ -0,0 +1,196 @@ +configureGitHubApp(); + + config([ + 'services.github.client_id' => 'legacy-client', + 'services.github.client_secret' => 'legacy-secret', + ]); + } + + private function fakeSocialiteUser(User $user, string $intent): void + { + $socialiteUser = Mockery::mock(SocialiteUser::class); + $socialiteUser->id = $user->github_id; + $socialiteUser->nickname = $user->github_username; + $socialiteUser->name = $user->name; + $socialiteUser->email = $user->email; + $socialiteUser->token = 'ghu_app_token'; + $socialiteUser->refreshToken = 'ghr_app_refresh'; + $socialiteUser->expiresIn = 28800; + + $provider = Mockery::mock(GithubProvider::class); + $provider->shouldReceive('user')->andReturn($socialiteUser); + + Socialite::shouldReceive('driver')->with('github-app')->andReturn($provider); + + session([ + 'github_auth_intent' => $intent, + 'github_auth_driver' => 'github-app', + ]); + } + + public function test_login_stores_the_refresh_token_and_expiry(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create(); + $this->fakeSocialiteUser($user, 'login'); + + $this->get('/auth/github/callback'); + + $user->refresh(); + + $this->assertSame('ghr_app_refresh', $user->getGitHubRefreshToken()); + $this->assertTrue($user->github_token_expires_at->between(now()->addHours(7), now()->addHours(9))); + } + + public function test_an_expired_token_is_refreshed_before_use(): void + { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response([ + 'access_token' => 'ghu_fresh', + 'refresh_token' => 'ghr_fresh', + 'expires_in' => 28800, + ]), + ]); + + $user = User::factory()->withGitHubApp()->create([ + 'github_refresh_token' => encrypt('ghr_old'), + 'github_token_expires_at' => now()->subMinute(), + ]); + + $this->assertSame('ghu_fresh', $user->getGitHubToken()); + + Http::assertSent(fn (Request $request) => $request['grant_type'] === 'refresh_token' + && $request['refresh_token'] === 'ghr_old' + && $request['client_id'] === 'Iv1.testclient'); + + $user->refresh(); + + $this->assertSame('ghr_fresh', $user->getGitHubRefreshToken()); + $this->assertTrue($user->github_token_expires_at->isFuture()); + } + + public function test_a_token_that_has_not_expired_is_used_as_is(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create([ + 'github_token' => encrypt('ghu_current'), + 'github_refresh_token' => encrypt('ghr_current'), + 'github_token_expires_at' => now()->addHours(4), + ]); + + $this->assertSame('ghu_current', $user->getGitHubToken()); + + Http::assertNothingSent(); + } + + public function test_a_rejected_refresh_token_clears_the_stored_tokens(): void + { + Http::fake([ + 'github.com/login/oauth/access_token' => Http::response(['error' => 'bad_refresh_token']), + ]); + + $user = User::factory()->withGitHubApp()->create([ + 'github_refresh_token' => encrypt('ghr_revoked'), + 'github_token_expires_at' => now()->subMinute(), + ]); + + $this->assertNull($user->getGitHubToken()); + + $user->refresh(); + + $this->assertNull($user->github_token); + $this->assertNull($user->github_refresh_token); + $this->assertNotNull($user->github_id); + } + + public function test_upgrading_by_linking_revokes_the_legacy_oauth_grant(): void + { + Http::fake(); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + $this->fakeSocialiteUser($user, 'link'); + + $this->actingAs($user)->get('/auth/github/callback'); + + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.github.com/applications/legacy-client/grant' + && $request['access_token'] === 'gho_legacy' + && $request->hasHeader('Authorization', 'Basic '.base64_encode('legacy-client:legacy-secret'))); + + $this->assertEquals(GitHubAuthType::App, $user->fresh()->github_auth_type); + } + + public function test_upgrading_by_logging_in_revokes_the_legacy_oauth_grant(): void + { + Http::fake(); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + $this->fakeSocialiteUser($user, 'login'); + + $this->get('/auth/github/callback'); + + Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' + && str_ends_with($request->url(), '/applications/legacy-client/grant') + && $request['access_token'] === 'gho_legacy'); + } + + public function test_github_app_users_logging_in_again_do_not_trigger_a_revoke(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create(); + $this->fakeSocialiteUser($user, 'login'); + + $this->get('/auth/github/callback'); + + Http::assertNotSent(fn (Request $request) => str_contains($request->url(), '/grant')); + } + + public function test_disconnecting_clears_the_refresh_token(): void + { + Http::fake(); + + $user = User::factory()->withGitHubApp()->create([ + 'github_refresh_token' => encrypt('ghr_current'), + 'github_token_expires_at' => now()->addHours(4), + ]); + + $this->actingAs($user)->delete(route('github.disconnect')); + + $user->refresh(); + + $this->assertNull($user->github_refresh_token); + $this->assertNull($user->github_token_expires_at); + } +} diff --git a/tests/Feature/GitHubLegacyOAuthRetirementTest.php b/tests/Feature/GitHubLegacyOAuthRetirementTest.php new file mode 100644 index 000000000..1aec6067d --- /dev/null +++ b/tests/Feature/GitHubLegacyOAuthRetirementTest.php @@ -0,0 +1,118 @@ + now()->addDay()->toDateString()]); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + + $this->assertSame('gho_legacy', $user->getGitHubToken()); + } + + public function test_legacy_tokens_are_ignored_once_the_cutoff_date_has_passed(): void + { + config([ + 'services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString(), + 'services.github.token' => 'ghp_platform', + ]); + + $user = User::factory()->withLegacyGitHub()->create(['github_token' => encrypt('gho_legacy')]); + + $this->assertNull($user->getGitHubToken()); + $this->assertFalse($user->hasGitHubToken()); + $this->assertSame('ghp_platform', GitHubUserService::for($user)->resolveTokenForRepo('acme', 'public-plugin')); + } + + public function test_github_app_tokens_are_unaffected_by_the_cutoff_date(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $user = User::factory()->withGitHubApp()->create(['github_token' => encrypt('ghu_app')]); + + $this->assertSame('ghu_app', $user->getGitHubToken()); + } + + public function test_command_refuses_to_run_before_the_cutoff_date(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->addWeek()->toDateString()]); + + $user = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:retire-legacy-oauth') + ->expectsOutputToContain("hasn't passed yet") + ->assertFailed(); + + $this->assertNotNull($user->fresh()->github_token); + } + + public function test_command_refuses_to_run_without_a_cutoff_date(): void + { + $this->artisan('github:retire-legacy-oauth') + ->expectsOutputToContain('GITHUB_LEGACY_OAUTH_CUTOFF_DATE') + ->assertFailed(); + } + + public function test_command_clears_legacy_tokens_but_keeps_github_identity(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $legacy = User::factory()->withLegacyGitHub()->create(); + $appUser = User::factory()->withGitHubApp()->create(['github_token' => encrypt('ghu_app')]); + + $this->artisan('github:retire-legacy-oauth') + ->expectsConfirmation('Clear 1 legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.', 'yes') + ->expectsOutputToContain('Cleared 1 legacy token(s).') + ->assertSuccessful(); + + $legacy->refresh(); + + $this->assertNull($legacy->github_token); + $this->assertNotNull($legacy->github_id); + $this->assertNotNull($legacy->github_username); + $this->assertEquals(GitHubAuthType::OAuth, $legacy->github_auth_type); + $this->assertSame('ghu_app', $appUser->fresh()->getGitHubToken()); + } + + public function test_command_lists_plugin_authors_who_never_moved_over(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $author = User::factory()->withLegacyGitHub()->create(); + Plugin::factory()->create(['user_id' => $author->id, 'name' => 'acme/camera-plugin']); + + $this->artisan('github:retire-legacy-oauth --dry-run') + ->expectsOutputToContain('1 of them plugin authors') + ->expectsTable(['User', 'Email', 'Plugins'], [[$author->id, $author->email, 'acme/camera-plugin']]) + ->expectsOutputToContain('DRY RUN') + ->assertSuccessful(); + + $this->assertNotNull($author->fresh()->github_token); + } + + public function test_declining_the_confirmation_changes_nothing(): void + { + config(['services.github.legacy_oauth_cutoff_date' => now()->subDay()->toDateString()]); + + $legacy = User::factory()->withLegacyGitHub()->create(); + + $this->artisan('github:retire-legacy-oauth') + ->expectsConfirmation('Clear 1 legacy token(s)? GitHub IDs and usernames are kept, so sign-in and repo invites keep working.', 'no') + ->expectsOutputToContain('Nothing changed.') + ->assertSuccessful(); + + $this->assertNotNull($legacy->fresh()->github_token); + } +} diff --git a/tests/Feature/GitHubMigrationBannerTest.php b/tests/Feature/GitHubMigrationBannerTest.php index b42ba9ff3..47be6caa5 100644 --- a/tests/Feature/GitHubMigrationBannerTest.php +++ b/tests/Feature/GitHubMigrationBannerTest.php @@ -5,6 +5,7 @@ use App\Features\ShowAuthButtons; use App\Features\ShowPlugins; use App\Livewire\Customer\Plugins\Create; +use App\Models\Plugin; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; @@ -32,9 +33,28 @@ public function test_legacy_oauth_user_sees_migration_banner_on_integrations(): $response = $this->actingAs($user)->get('/dashboard/integrations'); + $response->assertStatus(200); + $response->assertSee("We've Improved Our GitHub Connection", false); + $response->assertSee("You don't need to do anything.", false); + $response->assertSee('Reconnect GitHub'); + $response->assertDontSee('GitHub Connection Upgrade Required'); + } + + public function test_legacy_plugin_author_sees_urgent_banner_with_deadline(): void + { + Http::fake(['api.github.com/*' => Http::response([], 404)]); + config(['services.github.legacy_oauth_cutoff_date' => '2026-12-31']); + + $user = User::factory()->withLegacyGitHub()->create(); + Plugin::factory()->create(['user_id' => $user->id]); + + $response = $this->actingAs($user)->get('/dashboard/integrations'); + $response->assertStatus(200); $response->assertSee('GitHub Connection Upgrade Required'); - $response->assertSee('Upgrade GitHub Connection'); + $response->assertSee("If you don't do it before 31 December 2026", false); + $response->assertSee('may remove them from the Marketplace'); + $response->assertSee('Connect the GitHub App'); } public function test_github_app_user_does_not_see_migration_banner(): void @@ -72,7 +92,7 @@ public function test_legacy_oauth_user_sees_banner_on_plugins_index(): void $response = $this->get('/dashboard/developer/plugins'); $response->assertStatus(200); - $response->assertSee('GitHub Connection Upgrade Required'); + $response->assertSee("We've Improved Our GitHub Connection", false); } public function test_legacy_oauth_user_is_blocked_from_plugin_creation_via_livewire(): void @@ -100,6 +120,6 @@ public function test_legacy_oauth_user_sees_blocking_banner_on_plugin_create(): $response->assertStatus(200); $response->assertSee('GitHub Connection Upgrade Required'); - $response->assertSee('You must upgrade your GitHub connection before you can submit or manage plugins.'); + $response->assertSee('You need to connect the GitHub App before you can create a plugin.'); } } diff --git a/tests/Unit/GitHubAppServiceTest.php b/tests/Unit/GitHubAppServiceTest.php index e48ca7d00..9a9dc039a 100644 --- a/tests/Unit/GitHubAppServiceTest.php +++ b/tests/Unit/GitHubAppServiceTest.php @@ -5,13 +5,30 @@ use App\Models\GitHubInstallation; use App\Models\User; use App\Services\GitHubAppService; +use Firebase\JWT\JWT; +use Firebase\JWT\Key; use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\Concerns\InteractsWithGitHubApp; use Tests\TestCase; class GitHubAppServiceTest extends TestCase { + use InteractsWithGitHubApp; use RefreshDatabase; + public function test_jwt_is_signed_with_the_app_private_key(): void + { + $publicKey = $this->configureGitHubApp(); + + $jwt = (new GitHubAppService)->generateJwt(); + $claims = JWT::decode($jwt, new Key($publicKey, 'RS256')); + + $this->assertSame('12345', $claims->iss); + $this->assertLessThanOrEqual(time(), $claims->iat); + $this->assertGreaterThan(time(), $claims->exp); + $this->assertLessThanOrEqual(10 * 60, $claims->exp - $claims->iat); + } + public function test_find_installation_for_repo_with_all_repos_selected(): void { $user = User::factory()->withGitHubApp()->create(); From 072e98565c757d16fe7d9dd5e2160d5d1008f2c6 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 25 Sep 2026 20:49:57 +0100 Subject: [PATCH 4/5] Fix CI: handle an empty GitHub App key path and Pint issues CI copies .env.example, which has an empty GITHUB_APP_PRIVATE_KEY_PATH. That reached file_get_contents('') and threw a ValueError that the surrounding catch blocks don't handle. Treat an empty value as unset and throw a clear exception when the key file is missing. Also fixes import style in four files Pint flagged, and moves GitHubAppServiceTest into tests/Feature, since phpunit.xml only runs the Feature suite. Co-Authored-By: Claude Opus 5.5 --- app/Livewire/GitHubAppStatus.php | 3 ++- app/Models/GitHubInstallation.php | 3 ++- app/Services/GitHubAppService.php | 6 ++++++ config/services.php | 2 +- .../factories/GitHubInstallationFactory.php | 3 ++- tests/Feature/GitHubAppAuthTest.php | 9 ++++---- .../GitHubAppServiceTest.php | 21 ++++++++++++++++++- 7 files changed, 38 insertions(+), 9 deletions(-) rename tests/{Unit => Feature}/GitHubAppServiceTest.php (83%) diff --git a/app/Livewire/GitHubAppStatus.php b/app/Livewire/GitHubAppStatus.php index 3b58a7adc..cc719686b 100644 --- a/app/Livewire/GitHubAppStatus.php +++ b/app/Livewire/GitHubAppStatus.php @@ -3,11 +3,12 @@ namespace App\Livewire; use Illuminate\Support\Facades\Auth; +use Illuminate\View\View; use Livewire\Component; class GitHubAppStatus extends Component { - public function render(): \Illuminate\View\View + public function render(): View { $user = Auth::user(); $installations = $user->githubInstallations()->orderBy('account_login')->get(); diff --git a/app/Models/GitHubInstallation.php b/app/Models/GitHubInstallation.php index 5db89e46e..63434f88e 100644 --- a/app/Models/GitHubInstallation.php +++ b/app/Models/GitHubInstallation.php @@ -2,13 +2,14 @@ namespace App\Models; +use Database\Factories\GitHubInstallationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; class GitHubInstallation extends Model { - /** @use HasFactory<\Database\Factories\GitHubInstallationFactory> */ + /** @use HasFactory */ use HasFactory; protected $table = 'github_installations'; diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php index 01fe9deaf..8ce15f5cc 100644 --- a/app/Services/GitHubAppService.php +++ b/app/Services/GitHubAppService.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use RuntimeException; class GitHubAppService { @@ -43,6 +44,11 @@ public function legacyOAuthHasBeenRetired(): bool public function generateJwt(): string { $privateKeyPath = config('services.github_app.private_key_path'); + + if (! $privateKeyPath || ! is_readable($privateKeyPath)) { + throw new RuntimeException('The GitHub App private key is missing. Check GITHUB_APP_PRIVATE_KEY_PATH.'); + } + $privateKey = file_get_contents($privateKeyPath); $appId = config('services.github_app.app_id'); diff --git a/config/services.php b/config/services.php index 919b690d7..c462b258d 100644 --- a/config/services.php +++ b/config/services.php @@ -73,7 +73,7 @@ 'app_id' => env('GITHUB_APP_ID'), 'client_id' => env('GITHUB_APP_CLIENT_ID'), 'client_secret' => env('GITHUB_APP_CLIENT_SECRET'), - 'private_key_path' => env('GITHUB_APP_PRIVATE_KEY_PATH', storage_path('github-app.pem')), + 'private_key_path' => env('GITHUB_APP_PRIVATE_KEY_PATH') ?: storage_path('github-app.pem'), 'webhook_secret' => env('GITHUB_APP_WEBHOOK_SECRET'), 'redirect' => env('APP_URL').'/auth/github/callback', 'slug' => env('GITHUB_APP_SLUG'), diff --git a/database/factories/GitHubInstallationFactory.php b/database/factories/GitHubInstallationFactory.php index 80d3fa931..c9c684c40 100644 --- a/database/factories/GitHubInstallationFactory.php +++ b/database/factories/GitHubInstallationFactory.php @@ -2,11 +2,12 @@ namespace Database\Factories; +use App\Models\GitHubInstallation; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; /** - * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\GitHubInstallation> + * @extends Factory */ class GitHubInstallationFactory extends Factory { diff --git a/tests/Feature/GitHubAppAuthTest.php b/tests/Feature/GitHubAppAuthTest.php index e31c46610..f6dd6f716 100644 --- a/tests/Feature/GitHubAppAuthTest.php +++ b/tests/Feature/GitHubAppAuthTest.php @@ -8,6 +8,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Pennant\Feature; use Laravel\Socialite\Facades\Socialite; +use Laravel\Socialite\Two\GithubProvider; use Laravel\Socialite\Two\User as SocialiteUser; use Mockery; use Tests\TestCase; @@ -32,7 +33,7 @@ public function test_login_via_github_app_stores_app_auth_type(): void $socialiteUser->email = 'test@example.com'; $socialiteUser->token = 'ghu_test_token'; - $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider = Mockery::mock(GithubProvider::class); $provider->shouldReceive('user')->andReturn($socialiteUser); Socialite::shouldReceive('driver') @@ -64,7 +65,7 @@ public function test_login_via_legacy_oauth_stores_oauth_auth_type(): void $socialiteUser->email = 'test@example.com'; $socialiteUser->token = 'gho_test_token'; - $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider = Mockery::mock(GithubProvider::class); $provider->shouldReceive('user')->andReturn($socialiteUser); Socialite::shouldReceive('driver') @@ -96,7 +97,7 @@ public function test_linking_github_app_updates_existing_user_auth_type(): void $socialiteUser->nickname = 'newusername'; $socialiteUser->token = 'ghu_new_token'; - $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider = Mockery::mock(GithubProvider::class); $provider->shouldReceive('user')->andReturn($socialiteUser); Socialite::shouldReceive('driver') @@ -141,7 +142,7 @@ public function test_github_app_redirect_uses_app_driver_when_configured(): void $user = User::factory()->create(); - $provider = Mockery::mock(\Laravel\Socialite\Two\GithubProvider::class); + $provider = Mockery::mock(GithubProvider::class); $provider->shouldReceive('scopes') ->with(['read:user', 'user:email']) ->andReturnSelf(); diff --git a/tests/Unit/GitHubAppServiceTest.php b/tests/Feature/GitHubAppServiceTest.php similarity index 83% rename from tests/Unit/GitHubAppServiceTest.php rename to tests/Feature/GitHubAppServiceTest.php index 9a9dc039a..12007277b 100644 --- a/tests/Unit/GitHubAppServiceTest.php +++ b/tests/Feature/GitHubAppServiceTest.php @@ -1,6 +1,6 @@ assertLessThanOrEqual(10 * 60, $claims->exp - $claims->iat); } + public function test_jwt_fails_clearly_when_the_private_key_is_missing(): void + { + config(['services.github_app.private_key_path' => '']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('GITHUB_APP_PRIVATE_KEY_PATH'); + + (new GitHubAppService)->generateJwt(); + } + + public function test_a_missing_private_key_does_not_break_installation_token_lookups(): void + { + config(['services.github_app.private_key_path' => '']); + + $installation = GitHubInstallation::factory()->create(); + + $this->assertNull((new GitHubAppService)->refreshInstallationToken($installation)); + } + public function test_find_installation_for_repo_with_all_repos_selected(): void { $user = User::factory()->withGitHubApp()->create(); From 916446038746039ffe89888f78922c92aab13309 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Sat, 26 Sep 2026 12:51:34 +0100 Subject: [PATCH 5/5] Read the GitHub App key from env and fix the app slug - Replace GITHUB_APP_PRIVATE_KEY_PATH with an inline GITHUB_APP_PRIVATE_KEY. It can be pasted across multiple lines or kept on one line with \n. - Set the app slug to nativephp-plugin-marketplace in config, since it's public and fixed, and drop GITHUB_APP_SLUG. - Build the Integrations panel's install links with installationUrl() like everywhere else. Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +-- app/Livewire/GitHubAppStatus.php | 3 ++- app/Services/GitHubAppService.php | 23 +++++++++++----- config/services.php | 5 ++-- .../livewire/git-hub-app-status.blade.php | 8 +++--- tests/Concerns/InteractsWithGitHubApp.php | 9 +++---- tests/Feature/GitHubAppServiceTest.php | 27 ++++++++++++++++--- 7 files changed, 54 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index 8697a1b2e..f890b9c76 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,6 @@ GITHUB_LEGACY_OAUTH_CUTOFF_DATE= GITHUB_APP_ID= GITHUB_APP_CLIENT_ID= GITHUB_APP_CLIENT_SECRET= -GITHUB_APP_PRIVATE_KEY_PATH= +# The app's PEM private key, in double quotes. Either paste it across multiple lines or put it on one line with \n for each line break. +GITHUB_APP_PRIVATE_KEY= GITHUB_APP_WEBHOOK_SECRET= -GITHUB_APP_SLUG= diff --git a/app/Livewire/GitHubAppStatus.php b/app/Livewire/GitHubAppStatus.php index cc719686b..31f9e15ba 100644 --- a/app/Livewire/GitHubAppStatus.php +++ b/app/Livewire/GitHubAppStatus.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Services\GitHubAppService; use Illuminate\Support\Facades\Auth; use Illuminate\View\View; use Livewire\Component; @@ -38,7 +39,7 @@ public function render(): View return view('livewire.git-hub-app-status', [ 'installations' => $installations, 'pluginCoverage' => $pluginCoverage, - 'slug' => config('services.github_app.slug'), + 'installUrl' => app(GitHubAppService::class)->installationUrl(), ]); } } diff --git a/app/Services/GitHubAppService.php b/app/Services/GitHubAppService.php index 8ce15f5cc..9e9a434be 100644 --- a/app/Services/GitHubAppService.php +++ b/app/Services/GitHubAppService.php @@ -43,13 +43,7 @@ public function legacyOAuthHasBeenRetired(): bool public function generateJwt(): string { - $privateKeyPath = config('services.github_app.private_key_path'); - - if (! $privateKeyPath || ! is_readable($privateKeyPath)) { - throw new RuntimeException('The GitHub App private key is missing. Check GITHUB_APP_PRIVATE_KEY_PATH.'); - } - - $privateKey = file_get_contents($privateKeyPath); + $privateKey = $this->privateKey(); $appId = config('services.github_app.app_id'); $now = time(); @@ -63,6 +57,21 @@ public function generateJwt(): string return JWT::encode($payload, $privateKey, 'RS256'); } + /** + * The app's PEM private key from GITHUB_APP_PRIVATE_KEY. Some hosting dashboards store + * multi-line values with literal "\n" sequences, so those are turned back into newlines. + */ + protected function privateKey(): string + { + $privateKey = trim((string) config('services.github_app.private_key')); + + if ($privateKey === '') { + throw new RuntimeException('The GitHub App private key is missing. Set GITHUB_APP_PRIVATE_KEY.'); + } + + return str_replace('\n', "\n", $privateKey); + } + public function getInstallationToken(GitHubInstallation $installation): ?string { $cacheKey = "github_installation_token_{$installation->installation_id}"; diff --git a/config/services.php b/config/services.php index c462b258d..3b12d4196 100644 --- a/config/services.php +++ b/config/services.php @@ -73,10 +73,11 @@ 'app_id' => env('GITHUB_APP_ID'), 'client_id' => env('GITHUB_APP_CLIENT_ID'), 'client_secret' => env('GITHUB_APP_CLIENT_SECRET'), - 'private_key_path' => env('GITHUB_APP_PRIVATE_KEY_PATH') ?: storage_path('github-app.pem'), + 'private_key' => env('GITHUB_APP_PRIVATE_KEY'), 'webhook_secret' => env('GITHUB_APP_WEBHOOK_SECRET'), 'redirect' => env('APP_URL').'/auth/github/callback', - 'slug' => env('GITHUB_APP_SLUG'), + // Public and fixed: the app's name in its github.com/apps/{slug} URL + 'slug' => 'nativephp-plugin-marketplace', ], 'discord' => [ diff --git a/resources/views/livewire/git-hub-app-status.blade.php b/resources/views/livewire/git-hub-app-status.blade.php index a108a381c..161bf1fbf 100644 --- a/resources/views/livewire/git-hub-app-status.blade.php +++ b/resources/views/livewire/git-hub-app-status.blade.php @@ -7,8 +7,8 @@ Manage which accounts and repositories the NativePHP app can access.

- @if($slug) - + @if($installUrl) + Add Account @@ -22,8 +22,8 @@

No GitHub App installations found. Install the app on your GitHub account to grant repository access.

- @if($slug) -
+ @if($installUrl) + diff --git a/tests/Concerns/InteractsWithGitHubApp.php b/tests/Concerns/InteractsWithGitHubApp.php index 0ab4932d4..5b6a95ef2 100644 --- a/tests/Concerns/InteractsWithGitHubApp.php +++ b/tests/Concerns/InteractsWithGitHubApp.php @@ -5,7 +5,7 @@ trait InteractsWithGitHubApp { /** - * Configure the GitHub App with a real RSA key so JWTs can be signed, returning the public key. + * Configure the GitHub App with a real RSA private key so JWTs can be signed, returning the public key. */ protected function configureGitHubApp(): string { @@ -15,17 +15,14 @@ protected function configureGitHubApp(): string $key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); openssl_pkey_export($key, $privateKey); - $path = tempnam(sys_get_temp_dir(), 'github-app-key'); - file_put_contents($path, $privateKey); - - $keys = [$path, openssl_pkey_get_details($key)['key']]; + $keys = [$privateKey, openssl_pkey_get_details($key)['key']]; } config([ 'services.github_app.app_id' => '12345', 'services.github_app.client_id' => 'Iv1.testclient', 'services.github_app.client_secret' => 'test-app-secret', - 'services.github_app.private_key_path' => $keys[0], + 'services.github_app.private_key' => $keys[0], 'services.github_app.slug' => 'nativephp-test', ]); diff --git a/tests/Feature/GitHubAppServiceTest.php b/tests/Feature/GitHubAppServiceTest.php index 12007277b..9b723873d 100644 --- a/tests/Feature/GitHubAppServiceTest.php +++ b/tests/Feature/GitHubAppServiceTest.php @@ -16,6 +16,14 @@ class GitHubAppServiceTest extends TestCase use InteractsWithGitHubApp; use RefreshDatabase; + public function test_installation_url_points_at_the_nativephp_app_by_default(): void + { + $this->assertSame( + 'https://github.com/apps/nativephp-plugin-marketplace/installations/new', + (new GitHubAppService)->installationUrl() + ); + } + public function test_jwt_is_signed_with_the_app_private_key(): void { $publicKey = $this->configureGitHubApp(); @@ -29,19 +37,32 @@ public function test_jwt_is_signed_with_the_app_private_key(): void $this->assertLessThanOrEqual(10 * 60, $claims->exp - $claims->iat); } + public function test_jwt_accepts_a_private_key_stored_on_one_line(): void + { + $publicKey = $this->configureGitHubApp(); + + config(['services.github_app.private_key' => str_replace("\n", '\n', config('services.github_app.private_key'))]); + + $this->assertStringNotContainsString("\n", config('services.github_app.private_key')); + + $claims = JWT::decode((new GitHubAppService)->generateJwt(), new Key($publicKey, 'RS256')); + + $this->assertSame('12345', $claims->iss); + } + public function test_jwt_fails_clearly_when_the_private_key_is_missing(): void { - config(['services.github_app.private_key_path' => '']); + config(['services.github_app.private_key' => '']); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('GITHUB_APP_PRIVATE_KEY_PATH'); + $this->expectExceptionMessage('GITHUB_APP_PRIVATE_KEY'); (new GitHubAppService)->generateJwt(); } public function test_a_missing_private_key_does_not_break_installation_token_lookups(): void { - config(['services.github_app.private_key_path' => '']); + config(['services.github_app.private_key' => '']); $installation = GitHubInstallation::factory()->create();