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.
This commit is contained in:
Andras Bacsai 2026-06-09 15:27:35 +02:00
parent 96adf9b0f4
commit bc2c6068ea
3 changed files with 146 additions and 50 deletions

View file

@ -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);

View file

@ -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]);
}
}

View file

@ -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(),