From bc2c6068eaa46d0339de6591996deb88c960be1d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:27:35 +0200 Subject: [PATCH] fix(github): sync app slug before building install URL Move GitHub App JWT generation and slug synchronization into shared helpers so installation URLs use the canonical GitHub slug. Encode GHE organization path segments and keep the app-scoped fallback for blank organizations. --- app/Livewire/Source/Github/Change.php | 56 ++----------------- bootstrap/helpers/github.php | 70 ++++++++++++++++++++++++ tests/Feature/GithubSourceChangeTest.php | 70 ++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 50 deletions(-) diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 648bfe6ee..aec4cd6d6 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -8,11 +8,7 @@ use App\Rules\SafeExternalUrl; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; -use Lcobucci\JWT\Configuration; -use Lcobucci\JWT\Signer\Key\InMemory; -use Lcobucci\JWT\Signer\Rsa\Sha256; use Livewire\Component; class Change extends Component @@ -303,64 +299,24 @@ public function getGithubAppNameUpdatePath() return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; } - private function generateGithubJwt($private_key, $app_id): string - { - $configuration = Configuration::forAsymmetricSigner( - new Sha256, - InMemory::plainText($private_key), - InMemory::plainText($private_key) - ); - - $now = time(); - - return $configuration->builder() - ->issuedBy((string) $app_id) - ->permittedFor('https://api.github.com') - ->identifiedBy((string) $now) - ->issuedAt(new \DateTimeImmutable("@{$now}")) - ->expiresAt(new \DateTimeImmutable('@'.($now + 600))) - ->getToken($configuration->signer(), $configuration->signingKey()) - ->toString(); - } - public function updateGithubAppName() { try { $this->authorize('update', $this->github_app); - $privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); - - if (! $privateKey) { + if (! PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id)) { $this->dispatch('error', 'No private key found for this GitHub App.'); return; } - $jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); + $appSlug = syncGithubAppName($this->github_app, true); - $response = Http::withHeaders([ - 'Accept' => 'application/vnd.github+json', - 'X-GitHub-Api-Version' => '2022-11-28', - 'Authorization' => "Bearer {$jwt}", - ])->get("{$this->github_app->api_url}/app"); - - if ($response->successful()) { - $app_data = $response->json(); - $app_slug = $app_data['slug'] ?? null; - - if ($app_slug) { - $this->github_app->name = $app_slug; - $this->name = str($app_slug)->kebab(); - $privateKey->name = "github-app-{$app_slug}"; - $privateKey->save(); - $this->github_app->save(); - $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); - } else { - $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); - } + if ($appSlug) { + $this->name = str($appSlug)->kebab(); + $this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.'); } else { - $error_message = $response->json()['message'] ?? 'Unknown error'; - $this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}"); + $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.'); } } catch (\Throwable $e) { return handleError($e, $this); diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index b39a18ea9..89233aa38 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -2,6 +2,7 @@ use App\Models\GithubApp; use App\Models\GitlabApp; +use App\Models\PrivateKey; use Carbon\Carbon; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Cache; @@ -117,8 +118,75 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m ]; } +function generateGithubAppJwt(string $privateKey, string|int $appId): string +{ + $algorithm = new Sha256; + $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); + $now = CarbonImmutable::now()->setTimezone('UTC'); + $now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s')); + + return $tokenBuilder + ->issuedBy((string) $appId) + ->issuedAt($now->modify('-1 minute')) + ->expiresAt($now->modify('+8 minutes')) + ->getToken($algorithm, InMemory::plainText($privateKey)) + ->toString(); +} + +function syncGithubAppName(GithubApp $source, bool $throw = false): ?string +{ + try { + if (blank($source->app_id) || blank($source->private_key_id)) { + return null; + } + + $privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id); + + if (! $privateKey) { + return null; + } + + $jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id); + + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + 'Authorization' => "Bearer {$jwt}", + ])->get("{$source->api_url}/app"); + + if (! $response->successful()) { + throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.')); + } + + $appSlug = data_get($response->json(), 'slug'); + + if (blank($appSlug)) { + return null; + } + + $source->name = $appSlug; + + if ($source->exists) { + $source->save(); + } + + $privateKey->name = "github-app-{$appSlug}"; + $privateKey->save(); + + return $appSlug; + } catch (Throwable $e) { + if ($throw) { + throw $e; + } + + return null; + } +} + function getInstallationPath(GithubApp $source): string { + syncGithubAppName($source); + $name = str(Str::kebab($source->name)); $baseUrl = rtrim($source->html_url, '/'); $host = parse_url($source->html_url, PHP_URL_HOST); @@ -137,6 +205,8 @@ function getInstallationPath(GithubApp $source): string $organization = str($source->organization)->trim('/'); if ($organization->isNotEmpty()) { + $organization = rawurlencode((string) $organization); + return "$baseUrl/$installation_path/$organization/$name/installations/new?".http_build_query(['state' => $state]); } } diff --git a/tests/Feature/GithubSourceChangeTest.php b/tests/Feature/GithubSourceChangeTest.php index 7148a7d7a..0b8030050 100644 --- a/tests/Feature/GithubSourceChangeTest.php +++ b/tests/Feature/GithubSourceChangeTest.php @@ -171,6 +171,68 @@ function validPrivateKey(): string ]); }); + test('installation path synchronizes github app slug before generating the url', function () { + Http::fake([ + 'https://api.github.com/app' => Http::response(['slug' => 'actual-github-slug']), + ]); + + $privateKey = PrivateKey::create([ + 'name' => 'github-app-local-name', + 'private_key' => validPrivateKey(), + 'team_id' => $this->team->id, + 'is_git_related' => true, + ]); + + $githubApp = GithubApp::create([ + 'name' => 'Local Display Name', + 'organization' => 'acme-enterprise', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://octocorp.ghe.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'app_id' => 12345, + 'private_key_id' => $privateKey->id, + 'team_id' => $this->team->id, + 'is_system_wide' => false, + ]); + + $installationUrl = getInstallationPath($githubApp); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/actual-github-slug/installations/new?') + ->and($githubApp->refresh()->name)->toBe('actual-github-slug') + ->and($privateKey->refresh()->name)->toBe('github-app-actual-github-slug'); + }); + + test('ghe.com installation path encodes the organization segment', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'id' => 123, + 'name' => 'provided-github-app', + 'organization' => '/acme enterprise/', + 'html_url' => 'https://octocorp.ghe.com', + 'team_id' => 456, + ]); + + $installationUrl = getInstallationPath($githubApp); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme%20enterprise/provided-github-app/installations/new?'); + }); + + test('ghe.com installation path keeps app scoped fallback when organization is blank', function () { + $githubApp = new GithubApp; + $githubApp->forceFill([ + 'id' => 123, + 'name' => 'provided-github-app', + 'organization' => null, + 'html_url' => 'https://octocorp.ghe.com', + 'team_id' => 456, + ]); + + $installationUrl = getInstallationPath($githubApp); + + expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/provided-github-app/installations/new?'); + }); + test('defaults webhook endpoint to app url when it is the first available endpoint', function () { config(['app.url' => 'http://localhost:8000']); @@ -231,6 +293,10 @@ function validPrivateKey(): string }); test('can mount with fully configured github app', function () { + Http::fake([ + 'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']), + ]); + $privateKey = PrivateKey::create([ 'name' => 'Test Key', 'private_key' => validPrivateKey(), @@ -265,6 +331,10 @@ function validPrivateKey(): string }); test('can update github app from null to valid values', function () { + Http::fake([ + 'https://api.github.com/app' => Http::response(['slug' => 'test-github-app']), + ]); + $privateKey = PrivateKey::create([ 'name' => 'Test Key', 'private_key' => validPrivateKey(),