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 authored and Andras Bacsai committed 2026-07-20 23:21:40 +02:00
1 parent 9d341d0bb9
commit a26091de0a
32 files changed
+2216 -26

No files matched your search

@@ -11,7 +11,7 @@
Open Repository
<x-external-link />
</a>
@if (data_get($application, 'source.is_public') === false)
@if (data_get($application, 'source.is_public') === false && $application->source instanceof \App\Models\GithubApp)
<a target="_blank" class="hover:no-underline flex items-center gap-1"
href="{{ getInstallationPath($application->source) }}">
Open Git App
@@ -67,7 +67,7 @@
@forelse ($sources as $source)
<div wire:key="{{ $source->name }}">
<x-modal-confirmation title="Change Git Source" :actions="['Change git source to ' . $source->name]" :buttonFullWidth="true"
:isHighlightedButton="$application->source_id === $source->id" :disabled="$application->source_id === $source->id"
:isHighlightedButton="$application->source_id === $source->id && $application->source_type === $source->getMorphClass()" :disabled="$application->source_id === $source->id && $application->source_type === $source->getMorphClass()"
submitAction="changeSource({{ $source->id }}, {{ $source->getMorphClass() }})"
:confirmWithText="true" confirmationText="Change Git Source"
confirmationLabel="Please confirm changing the git source by entering the text below"
@@ -76,7 +76,7 @@
<div class="flex items-center gap-2">
<div class="box-title">
{{ $source->name }}
@if ($application->source_id === $source->id)
@if ($application->source_id === $source->id && $application->source_type === $source->getMorphClass())
<span class="text-xs">(current)</span>
@endif
</div>
@@ -0,0 +1,152 @@
<div>
<div class="flex items-end gap-2">
<h1>Create a new Application</h1>
<x-modal-input buttonTitle="+ Add GitLab App" title="New GitLab App" closeOutside="false">
<livewire:source.gitlab.create />
</x-modal-input>
@if ($repositories->count() > 0 && $gitlab_app_id)
<x-forms.button wire:click.prevent="loadRepositories({{ $gitlab_app_id }})">
Refresh Repository List
</x-forms.button>
@endif
</div>
<div class="pb-4">Deploy any public or private Git repositories through a GitLab App.</div>
@if ($gitlab_apps->count() !== 0)
<div class="flex flex-col gap-2">
@if ($current_step === 'gitlab_apps')
<h2 class="pt-4 pb-4">Select a GitLab App</h2>
<div class="flex flex-col justify-center gap-2 text-left">
@foreach ($gitlab_apps as $glapp)
<div class="flex">
<div class="w-full gap-2 py-4 group coolbox"
wire:click.prevent="loadRepositories({{ $glapp->id }})"
wire:key="{{ $glapp->id }}">
<div class="flex mr-4">
<div class="flex flex-col mx-6">
<div class="box-title">
{{ data_get($glapp, 'name') }}
</div>
<div class="box-description">
{{ data_get($glapp, 'html_url') }}</div>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center">
<x-loading wire:loading wire:target="loadRepositories({{ $glapp->id }})" />
</div>
</div>
@endforeach
</div>
@endif
@if ($current_step === 'repository')
@if ($repositories->count() > 0)
<div class="flex flex-col gap-2 pb-6">
<div class="flex gap-2">
<x-forms.datalist class="w-full" label="Repository" placeholder="Search repositories..." wire:model.live="selected_project_id">
@foreach ($repositories as $repo)
<option value="{{ data_get($repo, 'id') }}">{{ data_get($repo, 'path_with_namespace') }}</option>
@endforeach
</x-forms.datalist>
</div>
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches" wire:target="loadBranches, selected_project_id">
Load Repository
<x-loading-on-button wire:loading.delay wire:target="loadBranches, selected_project_id" />
</x-forms.button>
</div>
@else
<div>No repositories found. Check your GitLab App configuration.</div>
@endif
@if ($branches->count() > 0)
<h2 class="text-lg font-bold">Configuration</h2>
<div class="flex flex-col gap-2 pb-6">
<form class="flex flex-col" wire:submit='submit'>
<div class="flex flex-col gap-2 pb-6">
<div class="flex gap-2">
<x-forms.select id="selected_branch_name" label="Branch">
<option value="default" disabled selected>Select a branch</option>
@foreach ($branches as $branch)
@if ($loop->first)
<option selected value="{{ data_get($branch, 'name') }}">
{{ data_get($branch, 'name') }}
</option>
@else
<option value="{{ data_get($branch, 'name') }}">
{{ data_get($branch, 'name') }}
</option>
@endif
@endforeach
</x-forms.select>
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
<option value="nixpacks">Nixpacks</option>
<option value="railpack">Railpack (Beta)</option>
<option value="static">Static</option>
<option value="dockerfile">Dockerfile</option>
<option value="dockercompose">Docker Compose</option>
</x-forms.select>
@if ($is_static)
<x-forms.input id="publish_directory" label="Publish Directory"
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
@endif
</div>
@if ($build_pack === 'dockercompose')
<div x-data="{
baseDir: '{{ $base_directory }}',
composeLocation: '{{ $docker_compose_location }}',
normalizePath(path) {
if (!path || path.trim() === '') return '/';
path = path.trim();
path = path.replace(/\/+$/, '');
if (!path.startsWith('/')) {
path = '/' + path;
}
return path;
},
normalizeBaseDir() {
this.baseDir = this.normalizePath(this.baseDir);
},
normalizeComposeLocation() {
this.composeLocation = this.normalizePath(this.composeLocation);
}
}" class="gap-2 flex flex-col">
<x-forms.input placeholder="/" wire:model.defer="base_directory"
label="Base Directory"
helper="Directory to use as root. Useful for monorepos." x-model="baseDir"
@blur="normalizeBaseDir()" />
<x-forms.input placeholder="/docker-compose.yaml"
wire:model.defer="docker_compose_location" label="Docker Compose Location"
helper="It is calculated together with the Base Directory."
x-model="composeLocation" @blur="normalizeComposeLocation()" />
<div class="pt-2">
<span>
Compose file location in your repository: </span><span
class='dark:text-warning'
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></span>
</div>
</div>
@else
<x-forms.input wire:model="base_directory" label="Base Directory"
helper="Directory to use as root. Useful for monorepos." />
@endif
@if ($show_is_static)
<x-forms.input type="number" id="port" label="Port" :readonly="$is_static || $build_pack === 'static'"
helper="The port your application listens on." />
<div class="w-52">
<x-forms.checkbox instantSave id="is_static" label="Is it a static site?"
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
</div>
@endif
</div>
<x-forms.button type="submit">
Continue
</x-forms.button>
</form>
</div>
@endif
@endif
</div>
@else
<div class="hero">
No connected GitLab Application found. Please <a href="/sources" class="underline">add a GitLab App</a> first.
</div>
@endif
</div>
@@ -6,6 +6,8 @@
<livewire:project.new.public-git-repository :type="$type" />
@elseif ($type === 'private-gh-app')
<livewire:project.new.github-private-repository :type="$type" />
@elseif ($type === 'private-gitlab-app')
<livewire:project.new.gitlab-private-repository :type="$type" />
@elseif ($type === 'private-deploy-key')
<livewire:project.new.github-private-repository-deploy-key :type="$type" />
@elseif ($type === 'dockerfile')
@@ -0,0 +1,175 @@
<div>
@if ($isConnected)
<form wire:submit='submit'>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitLab App</h1>
<div class="flex gap-2">
<x-forms.button canGate="update" :canResource="$gitlab_app" type="submit">Save</x-forms.button>
<x-forms.button wire:click.prevent="testConnection">Test Connection</x-forms.button>
@can('delete', $gitlab_app)
<x-modal-confirmation title="Confirm GitLab App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected GitLab App will be permanently deleted.']"
confirmationText="{{ data_get($gitlab_app, 'name') }}"
confirmationLabel="Please confirm by entering the GitLab App Name below"
shortConfirmationLabel="GitLab App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endcan
</div>
</div>
<div class="subtitle">Your GitLab App for private repositories.</div>
<div class="flex items-center gap-2 mb-4">
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded bg-success/10 text-success">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3" /></svg>
Connected
</span>
<x-forms.button wire:click.prevent="disconnect"
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline text-xs">
Disconnect
</x-forms.button>
</div>
<div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$gitlab_app" id="name" label="Name" />
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$gitlab_app" id="htmlUrl" label="GitLab URL" />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="apiUrl" label="API URL" />
</div>
<x-forms.input canGate="update" :canResource="$gitlab_app" id="groupName" label="Group Name"
helper="Comma-separated group names to filter visible repositories." />
<div class="flex gap-2">
<x-forms.input canGate="update" :canResource="$gitlab_app" id="customUser" label="SSH User" />
<x-forms.input canGate="update" :canResource="$gitlab_app" type="number" id="customPort" label="SSH Port" />
</div>
@if (!isCloud())
<div class="w-48">
<x-forms.checkbox canGate="update" :canResource="$gitlab_app" label="System Wide?"
helper="If checked, this GitLab App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@endif
<h3 class="pt-4">OAuth Credentials</h3>
<x-forms.input canGate="update" :canResource="$gitlab_app" id="clientId" label="Application ID" />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="clientSecretInput" label="Application Secret" type="password" />
<h3 class="pt-4">Webhook</h3>
<div class="flex flex-col gap-1">
<div class="text-sm">
Configure this webhook URL in your GitLab project settings
(<code>Settings &gt; Webhooks</code>):
</div>
<x-forms.input readonly label="Webhook URL"
value="{{ $webhook_endpoint }}/webhooks/source/gitlab/events" />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="webhookToken" label="Webhook Secret Token"
helper="Set this same token in your GitLab webhook's 'Secret token' field." />
</div>
<h3 class="pt-4">SSH Key (Optional)</h3>
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Only needed if you prefer SSH-based git clone over HTTPS OAuth token.
</div>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$gitlab_app" id="privateKeyId" label="Private Key">
<option value="">None</option>
@foreach ($privateKeys as $key)
<option value="{{ $key->id }}">{{ $key->name }}</option>
@endforeach
</x-forms.select>
</div>
@if ($applications->count() > 0)
<h3 class="pt-4">Applications Using This Source</h3>
<div class="flex flex-col gap-2">
@foreach ($applications as $application)
<a class="coolbox group"
href="{{ route('project.application.configuration', [
'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => data_get($application, 'uuid'),
]) }}">
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">
<div class="box-title">{{ $application->name }}</div>
<div class="box-description">{{ $application->git_repository }}:{{ $application->git_branch }}</div>
</div>
</a>
@endforeach
</div>
@endif
</div>
</form>
@else
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitLab App</h1>
<div class="flex gap-2">
@can('delete', $gitlab_app)
<x-modal-confirmation title="Confirm GitLab App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected GitLab App will be permanently deleted.']"
confirmationText="{{ data_get($gitlab_app, 'name') }}"
confirmationLabel="Please confirm by entering the GitLab App Name below"
shortConfirmationLabel="GitLab App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endcan
</div>
</div>
<div class="subtitle">Connect your GitLab instance to deploy private repositories.</div>
<div class="mb-6 rounded-sm alert-warning">
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span>Complete the setup below to connect this GitLab source.</span>
</div>
<div class="flex flex-col gap-4">
<h3>Step 1: Create an OAuth Application on GitLab</h3>
<div class="text-sm flex flex-col gap-1">
<p>Go to your GitLab instance and create a new OAuth Application:</p>
<a href="{{ rtrim($htmlUrl, '/') }}/-/profile/applications" target="_blank"
class="inline-flex items-center gap-1 text-sm underline">
{{ rtrim($htmlUrl, '/') }}/-/profile/applications
<x-external-link />
</a>
<ul class="list-disc list-inside mt-2 space-y-1">
<li>Set <strong>Redirect URI</strong> to: <code>{{ $redirectUri }}</code></li>
<li>Enable scopes: <code>api</code>, <code>read_user</code>, <code>read_repository</code></li>
<li>Uncheck <strong>Confidential</strong> if you run into issues</li>
</ul>
</div>
<h3 class="pt-2">Step 2: Enter the credentials</h3>
<form wire:submit='submit' class="flex flex-col gap-2">
<x-forms.input id="name" label="Name" />
<div class="flex gap-2">
<x-forms.input id="htmlUrl" label="GitLab URL" />
<x-forms.input id="apiUrl" label="API URL" />
</div>
<x-forms.input id="clientId" label="Application ID" required
helper="The Application ID from your GitLab OAuth Application." />
<x-forms.input id="clientSecretInput" label="Application Secret" type="password" required
helper="The Secret from your GitLab OAuth Application." />
<x-forms.input id="groupName" label="Group Name"
helper="Optional. Comma-separated group names to filter repositories." />
<div class="flex gap-2">
<x-forms.input id="customUser" label="SSH User" />
<x-forms.input type="number" id="customPort" label="SSH Port" />
</div>
@if (!isCloud())
<x-forms.checkbox label="System Wide?" id="isSystemWide"
helper="If checked, this GitLab App will be available for everyone in this Coolify instance." />
@endif
<x-forms.button type="submit" class="mt-2">Save Credentials</x-forms.button>
</form>
@if ($clientId)
<h3 class="pt-2">Step 3: Authorize with GitLab</h3>
<div class="text-sm">Click the button below to authorize Coolify with your GitLab instance.</div>
<a href="{{ $this->getOAuthUrl() }}" class="w-fit">
<x-forms.button class="mt-2">
Connect to GitLab
</x-forms.button>
</a>
@endif
</div>
@endif
</div>
@@ -0,0 +1,18 @@
<div>
<form class="flex flex-col gap-2" wire:submit='createGitLabApp'>
<div class="flex gap-2">
<h2>New GitLab App</h2>
<x-forms.button type="submit">Save</x-forms.button>
</div>
<div class="subtitle">Add a self-hosted or GitLab.com instance as a source for your applications.</div>
<x-forms.input id="name" label="Name" required />
<x-forms.input id="html_url" label="GitLab URL" required
helper="For self-hosted GitLab, enter your instance URL (e.g., https://gitlab.example.com)." />
<x-forms.input id="group_name" label="Group Name"
helper="Optional. Comma-separated group names to filter repositories (e.g., myorg,myteam)." />
@if (!isCloud())
<x-forms.checkbox label="System Wide?" id="is_system_wide"
helper="If checked, this GitLab App will be available for everyone in this Coolify instance." />
@endif
</form>
</div>
+20 -2
View File
@@ -5,9 +5,12 @@
<div class="flex items-center gap-2">
<h1>Sources</h1>
@can('createAnyResource')
<x-modal-input buttonTitle="+ Add" title="New GitHub App" :closeOutside="false">
<x-modal-input buttonTitle="+ Add GitHub" title="New GitHub App" :closeOutside="false">
<livewire:source.github.create />
</x-modal-input>
<x-modal-input buttonTitle="+ Add GitLab" title="New GitLab App" :closeOutside="false">
<livewire:source.gitlab.create />
</x-modal-input>
@endcan
</div>
<div class="subtitle">Git sources for your applications.</div>
@@ -17,7 +20,6 @@
<a class="flex gap-2 text-center hover:no-underline coolbox group"
{{ wireNavigate() }}
href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
{{-- <x-git-icon class="dark:text-white w-8 h-8 mt-1" git="{{ $source->getMorphClass() }}" /> --}}
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">
<div class="box-title">{{ $source->name }}</div>
@if (is_null($source->app_id))
@@ -29,6 +31,22 @@
@endif
</div>
</a>
@elseif ($source->getMorphClass() === 'App\Models\GitlabApp')
<a class="flex gap-2 text-center hover:no-underline coolbox group"
{{ wireNavigate() }}
href="{{ route('source.gitlab.show', ['gitlab_app_uuid' => data_get($source, 'uuid')]) }}">
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">
<div class="box-title">
<x-git-icon class="inline-block w-4 h-4 mr-1" git="App\Models\GitlabApp" />
{{ $source->name }}
</div>
@if ($source->isConnected())
<span class="box-description text-success">Connected — {{ $source->html_url }}</span>
@else
<span class="box-description text-warning">Setup required — {{ $source->html_url }}</span>
@endif
</div>
</a>
@endif
@empty
<div>