feat: self-hosted GitLab Apps OAuth integration

Adds self-hosted GitLab OAuth sources so Coolify can connect to a self-managed GitLab instance, list private repositories, clone over an OAuth token, and deploy (the GitLab counterpart to GitHub Apps).

Hardening: authenticated, one-time team-bound OAuth callback state; token redaction in deploy logs; custom host port/path kept in clone and ls-remote URLs; submodule OAuth auth; system-wide source selection. Covered by unit and feature tests.

cosigned by OpenAI Codex at M1 Max
This commit is contained in:
Mike Chong
2026-07-20 23:21:40 +02:00
committed by Andras Bacsai
parent 9d341d0bb9
commit a26091de0a
32 changed files with 2216 additions and 26 deletions
+83 -5
View File
@@ -1378,11 +1378,11 @@ class Application extends BaseModel
if ($this->deploymentType() === 'source') {
$source_html_url = data_get($this, 'source.html_url');
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL));
$source_html_url_host = $url['host'];
$source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: [];
$source_html_url_host = $url['host'] ?? '';
$source_html_url_scheme = $url['scheme'] ?? '';
$escapedCustomRepository = escapeshellarg($customRepository);
if ($this->source->is_public) {
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
@@ -1420,6 +1420,32 @@ class Application extends BaseModel
if ($this->source->getMorphClass() === GitlabApp::class) {
$gitlabSource = $this->source;
if ($gitlabSource->isConnected()) {
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: [];
$source_html_url_host = $this->urlHostWithPort($url);
$source_html_url_scheme = $url['scheme'] ?? '';
$token = generateGitlabCloneToken($gitlabSource);
$encodedToken = rawurlencode($token);
$pathPrefix = rtrim($url['path'] ?? '', '/');
$repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$fullRepoUrl = $repoUrl;
$base_command = "{$base_command} {$escapedRepoUrl}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
$private_key = data_get($gitlabSource, 'privateKey.private_key');
if ($private_key) {
@@ -1444,7 +1470,6 @@ class Application extends BaseModel
];
}
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$base_command = "{$base_command} {$escapedCustomRepository}";
@@ -1677,6 +1702,52 @@ class Application extends BaseModel
if ($this->source->getMorphClass() === GitlabApp::class) {
$gitlabSource = $this->source;
if ($gitlabSource->isConnected()) {
$token = generateGitlabCloneToken($gitlabSource);
$encodedToken = rawurlencode($token);
$pathPrefix = rtrim($url['path'] ?? '', '/');
$source_html_url_host = $this->urlHostWithPort($url ?: []);
// Rewrite same-host HTTPS submodule URLs to auth with the OAuth token (mirrors the GitHub path) without persisting credentials.
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}{$pathPrefix}/");
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
$repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$fullRepoUrl = $repoUrl;
$git_clone_command_base = $this->applyGitConfigOptionsToCloneCommand("{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}", $gitConfigOptions);
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($pull_request_id !== 0) {
$branch = "merge-requests/{$pull_request_id}/head:{$pr_branch_name}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out {$branch}'"));
} else {
$commands->push("echo 'Checking out {$branch}'");
}
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions);
$escapedPrBranch = escapeshellarg($branch);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOptions} fetch origin {$escapedPrBranch} && {$git_checkout_command}";
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
} else {
$commands->push($git_clone_command);
}
return [
'commands' => $commands->implode(' && '),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
$private_key = data_get($gitlabSource, 'privateKey.private_key');
if ($private_key) {
@@ -1717,7 +1788,6 @@ class Application extends BaseModel
];
}
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
$fullRepoUrl = $customRepository;
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
@@ -2382,6 +2452,14 @@ class Application extends BaseModel
];
}
private function urlHostWithPort(array $url): string
{
$host = $url['host'] ?? '';
$port = isset($url['port']) ? ":{$url['port']}" : '';
return "{$host}{$port}";
}
public function generateConfig($is_json = false)
{
$generator = new ConfigurationGenerator($this);