Merge remote-tracking branch 'origin/next' into ghe-support-helpers

This commit is contained in:
Andras Bacsai 2026-07-03 10:26:58 +02:00
commit a7dddccd2e
3 changed files with 240 additions and 65 deletions

View file

@ -8,11 +8,8 @@
use App\Rules\SafeExternalUrl; use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration; use Illuminate\Validation\ValidationException;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Livewire\Component; use Livewire\Component;
class Change extends Component class Change extends Component
@ -216,6 +213,8 @@ public function checkPermissions()
return; return;
} }
syncGithubAppName($this->github_app);
GithubAppPermissionJob::dispatchSync($this->github_app); GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false); $this->syncData(false);
@ -314,64 +313,34 @@ public function getGithubAppNameUpdatePath()
return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}"; return rtrim($this->github_app->html_url, '/')."/settings/apps/{$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($this->github_app->api_url)
->identifiedBy((string) $now)
->issuedAt(new \DateTimeImmutable("@{$now}"))
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
->getToken($configuration->signer(), $configuration->signingKey())
->toString();
}
public function updateGithubAppName() public function updateGithubAppName()
{ {
try { try {
$this->authorize('update', $this->github_app); $this->authorize('update', $this->github_app);
$privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); $this->github_app->app_id = $this->appId;
$this->github_app->private_key_id = $this->privateKeyId;
$this->github_app->unsetRelation('privateKey');
if (! $privateKey) { if (! $this->appId) {
$this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.');
return;
}
if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) {
$this->dispatch('error', 'No private key found for this GitHub App.'); $this->dispatch('error', 'No private key found for this GitHub App.');
return; return;
} }
$jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); $appSlug = syncGithubAppName($this->github_app, true);
$response = Http::withHeaders([ if ($appSlug) {
'Accept' => 'application/vnd.github+json', $this->name = str($appSlug)->kebab();
'X-GitHub-Api-Version' => '2022-11-28', $this->dispatch('success', 'GitHub App name and private key name synchronized successfully.');
'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.');
}
} else { } else {
$error_message = $response->json()['message'] ?? 'Unknown error'; $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
$this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}");
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -391,6 +360,8 @@ public function submit()
$this->syncData(true); $this->syncData(true);
$this->github_app->save(); $this->github_app->save();
$this->dispatch('success', 'Github App updated.'); $this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -2,6 +2,7 @@
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\GitlabApp; use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -147,9 +148,9 @@ function encodeGithubPathSegment(string $segment): string
return rawurlencode($segment); return rawurlencode($segment);
} }
function generateGithubToken(GithubApp $source, string $type) function assertGithubClockInSync(string $apiUrl): void
{ {
$response = Http::get("{$source->api_url}/zen"); $response = Http::get("{$apiUrl}/zen");
$serverTime = CarbonImmutable::now()->setTimezone('UTC'); $serverTime = CarbonImmutable::now()->setTimezone('UTC');
$githubTime = Carbon::parse($response->header('date')); $githubTime = Carbon::parse($response->header('date'));
$timeDiff = abs($serverTime->diffInSeconds($githubTime)); $timeDiff = abs($serverTime->diffInSeconds($githubTime));
@ -163,6 +164,11 @@ function generateGithubToken(GithubApp $source, string $type)
'Please synchronize your system clock.' 'Please synchronize your system clock.'
); );
} }
}
function generateGithubToken(GithubApp $source, string $type)
{
assertGithubClockInSync($source->api_url);
$signingKey = InMemory::plainText($source->privateKey->private_key); $signingKey = InMemory::plainText($source->privateKey->private_key);
$algorithm = new Sha256; $algorithm = new Sha256;
@ -251,6 +257,73 @@ 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;
}
assertGithubClockInSync($source->api_url);
$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 function getInstallationPath(GithubApp $source): string
{ {
$name = encodeGithubPathSegment(Str::kebab($source->name)); $name = encodeGithubPathSegment(Str::kebab($source->name));

View file

@ -147,7 +147,111 @@ function validPrivateKey(): string
]); ]);
}); });
test('ghe dot com installation path includes encoded organization segment', function () { test('ghe.com installation path uses github cloud owner scoped route', 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);
parse_str(parse_url($installationUrl, PHP_URL_QUERY), $query);
$installState = $query['state'] ?? null;
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/provided-github-app/installations/new?')
->and($installState)->not->toBeEmpty()
->and(Cache::get('github-app-setup-state:'.hash('sha256', $installState)))
->toMatchArray([
'action' => 'install',
'github_app_id' => 123,
'team_id' => 456,
]);
});
test('installation path is pure and never calls github or mutates the app', function () {
Http::fake();
$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);
Http::assertNothingSent();
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/acme-enterprise/local-display-name/installations/new?')
->and($githubApp->refresh()->name)->toBe('Local Display Name')
->and($privateKey->refresh()->name)->toBe('github-app-local-name');
});
test('syncGithubAppName persists the github slug and renames the private key', function () {
Http::fake([
'*/app' => Http::response(['slug' => 'actual-github-slug']),
'*/zen' => Http::response('Keep it logically awesome.'),
]);
$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,
]);
$appSlug = syncGithubAppName($githubApp, true);
expect($appSlug)->toBe('actual-github-slug')
->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 encodes plus signs in the organization segment', function () {
$githubApp = new GithubApp; $githubApp = new GithubApp;
$githubApp->forceFill([ $githubApp->forceFill([
'id' => 123, 'id' => 123,
@ -157,8 +261,24 @@ function validPrivateKey(): string
'team_id' => 456, 'team_id' => 456,
]); ]);
expect(getInstallationPath($githubApp)) $installationUrl = getInstallationPath($githubApp);
->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-app/installations/new?');
expect($installationUrl)->toStartWith('https://octocorp.ghe.com/apps/octo%2Bcorp/provided-git-hub-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 () { test('defaults webhook endpoint to app url when it is the first available endpoint', function () {
@ -221,6 +341,10 @@ function validPrivateKey(): string
}); });
test('can mount with fully configured github app', function () { 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([ $privateKey = PrivateKey::create([
'name' => 'Test Key', 'name' => 'Test Key',
'private_key' => validPrivateKey(), 'private_key' => validPrivateKey(),
@ -255,6 +379,10 @@ function validPrivateKey(): string
}); });
test('can update github app from null to valid values', function () { 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([ $privateKey = PrivateKey::create([
'name' => 'Test Key', 'name' => 'Test Key',
'private_key' => validPrivateKey(), 'private_key' => validPrivateKey(),
@ -291,10 +419,10 @@ function validPrivateKey(): string
expect($githubApp->private_key_id)->toBe($privateKey->id); expect($githubApp->private_key_id)->toBe($privateKey->id);
}); });
test('normalizes ghe dot com api url when saving github app settings', function () { test('normalizes resolvable ghe dot com api url when saving github app settings', function () {
$githubApp = GithubApp::create([ $githubApp = GithubApp::create([
'name' => 'Test GitHub App', 'name' => 'Test GitHub App',
'api_url' => 'https://octocorp.ghe.com/api/v3', 'api_url' => 'https://github.ghe.com/api/v3',
'html_url' => 'https://github.com', 'html_url' => 'https://github.com',
'custom_user' => 'git', 'custom_user' => 'git',
'custom_port' => 22, 'custom_port' => 22,
@ -305,14 +433,14 @@ function validPrivateKey(): string
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid]) Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class) ->test(Change::class)
->assertSuccessful() ->assertSuccessful()
->set('htmlUrl', 'https://octocorp.ghe.com') ->set('htmlUrl', 'https://github.ghe.com')
->set('apiUrl', 'https://octocorp.ghe.com/api/v3') ->set('apiUrl', 'https://github.ghe.com/api/v3')
->call('submit') ->call('submit')
->assertDispatched('success') ->assertDispatched('success')
->assertSet('apiUrl', 'https://api.octocorp.ghe.com'); ->assertSet('apiUrl', 'https://api.github.ghe.com');
$githubApp->refresh(); $githubApp->refresh();
expect($githubApp->api_url)->toBe('https://api.octocorp.ghe.com'); expect($githubApp->api_url)->toBe('https://api.github.ghe.com');
}); });
test('rejects invalid github organization values', function () { test('rejects invalid github organization values', function () {
@ -487,7 +615,7 @@ function validPrivateKey(): string
->and($githubApp->pull_requests)->toBe('write'); ->and($githubApp->pull_requests)->toBe('write');
}); });
test('sync name uses normalized ghe dot com api url', function () { test('sync name uses normalized resolvable ghe dot com api url', function () {
$privateKey = PrivateKey::create([ $privateKey = PrivateKey::create([
'name' => 'Test Key', 'name' => 'Test Key',
'private_key' => validPrivateKey(), 'private_key' => validPrivateKey(),
@ -496,8 +624,8 @@ function validPrivateKey(): string
$githubApp = GithubApp::create([ $githubApp = GithubApp::create([
'name' => 'Test GitHub App', 'name' => 'Test GitHub App',
'api_url' => 'https://api.octocorp.ghe.com', 'api_url' => 'https://api.github.ghe.com',
'html_url' => 'https://octocorp.ghe.com', 'html_url' => 'https://github.ghe.com',
'custom_user' => 'git', 'custom_user' => 'git',
'custom_port' => 22, 'custom_port' => 22,
'app_id' => 12345, 'app_id' => 12345,
@ -509,7 +637,10 @@ function validPrivateKey(): string
Http::preventStrayRequests(); Http::preventStrayRequests();
Http::fake([ Http::fake([
'https://api.octocorp.ghe.com/app' => Http::response([ 'https://api.github.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.ghe.com/app' => Http::response([
'slug' => 'octocorp-app', 'slug' => 'octocorp-app',
]), ]),
]); ]);
@ -520,6 +651,6 @@ function validPrivateKey(): string
->call('updateGithubAppName') ->call('updateGithubAppName')
->assertDispatched('success'); ->assertDispatched('success');
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/app'); Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
}); });
}); });