From 4f509c02be77ec13d75f5bb90e8069e8b1fbfec5 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:17:45 +0200 Subject: [PATCH 1/4] fix(auth): validate invitation magic link tokens Accept invitation links across configured public origins while still rejecting stored invitations whose token no longer matches. --- app/Http/Controllers/Controller.php | 18 ++++++-- app/Livewire/Team/InviteLink.php | 14 +++++- config/constants.php | 1 - tests/Feature/InvitationLinkHandlingTest.php | 28 ++++++++++++ .../TeamInvitationPrivilegeEscalationTest.php | 44 +++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 3090538c3..a567c6244 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -98,7 +98,7 @@ public function forgot_password(Request $request) public function link() { $token = request()->get('token'); - if ($token) { + if (is_string($token) && $token !== '') { try { $decrypted = Crypt::decryptString($token); } catch (DecryptException) { @@ -126,9 +126,8 @@ public function link() $invitation = TeamInvitation::query() ->where('email', $email) ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) - ->where('link', request()->fullUrl()) ->first(); - if (! $invitation || ! $invitation->isValid()) { + if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) { return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); } @@ -152,6 +151,19 @@ public function link() return redirect()->route('login')->with('error', 'Invalid credentials.'); } + private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool + { + $query = parse_url($invitation->link, PHP_URL_QUERY); + if (! is_string($query)) { + return false; + } + + parse_str($query, $parameters); + $storedToken = $parameters['token'] ?? null; + + return is_string($storedToken) && hash_equals($storedToken, $token); + } + public function showInvitation() { $invitationUuid = request()->route('uuid'); diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php index fb30961e9..be4d36e38 100644 --- a/app/Livewire/Team/InviteLink.php +++ b/app/Livewire/Team/InviteLink.php @@ -40,6 +40,16 @@ public function viaLink() $this->generateInviteLink(sendEmail: false); } + private function invitationUrl(string $routeName, array $parameters): string + { + $fqdn = instanceSettings()->fqdn; + if (filled($fqdn)) { + return rtrim($fqdn, '/').route($routeName, $parameters, false); + } + + return route($routeName, $parameters); + } + private function generateInviteLink(bool $sendEmail = false) { try { @@ -62,7 +72,7 @@ private function generateInviteLink(bool $sendEmail = false) return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); } $uuid = (string) new Cuid2(32); - $link = url('/').config('constants.invitation.link.base_url').$uuid; + $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]); $user = User::whereEmail($this->email)->first(); if (is_null($user)) { @@ -74,7 +84,7 @@ private function generateInviteLink(bool $sendEmail = false) 'force_password_reset' => true, ]); $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); - $link = route('auth.link', ['token' => $token]); + $link = $this->invitationUrl('auth.link', ['token' => $token]); } $invitation = TeamInvitation::whereEmail($this->email)->first(); if (! is_null($invitation)) { diff --git a/config/constants.php b/config/constants.php index 4a956b31e..bb231967a 100644 --- a/config/constants.php +++ b/config/constants.php @@ -86,7 +86,6 @@ 'invitation' => [ 'link' => [ - 'base_url' => '/invitations/', 'expiration_days' => 3, ], ], diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php index e45207cc5..184dfd77f 100644 --- a/tests/Feature/InvitationLinkHandlingTest.php +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -77,6 +77,34 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array $this->assertGuest(); }); +it('accepts a magic link when opened from a different public origin', function () { + [$team, $user, $password, $token] = createInvitationLinkFixture(); + + $this->get('https://coolify.example.com/auth/link?token='.urlencode($token)) + ->assertRedirect(route('dashboard')); + + $this->assertAuthenticatedAs($user); + $this->assertDatabaseMissing('team_invitations', ['email' => $user->email]); + expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue(); + + $user->refresh(); + expect(Hash::check($password, $user->password))->toBeFalse(); +}); + +it('rejects a magic link when the stored invitation token differs', function () { + [, $user, , $token, $invitation] = createInvitationLinkFixture(); + $differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password"); + + $invitation->forceFill([ + 'link' => route('auth.link', ['token' => $differentToken]), + ])->save(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('login')); + + $this->assertGuest(); +}); + it('rejects a magic link when the invitation was revoked', function () { [, $user, , $token, $invitation] = createInvitationLinkFixture(); $invitation->delete(); diff --git a/tests/Feature/TeamInvitationPrivilegeEscalationTest.php b/tests/Feature/TeamInvitationPrivilegeEscalationTest.php index 9e011965a..af8a57cdd 100644 --- a/tests/Feature/TeamInvitationPrivilegeEscalationTest.php +++ b/tests/Feature/TeamInvitationPrivilegeEscalationTest.php @@ -1,7 +1,9 @@ InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null])); + // Create a team with owner, admin, and member $this->team = Team::factory()->create(); @@ -161,6 +165,46 @@ ]); }); + test('new user invitation magic link uses instance fqdn when configured', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['fqdn' => 'https://coolify.example.com'] + )); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(InviteLink::class) + ->set('email', 'fqdn-invitee@example.com') + ->set('role', 'member') + ->call('viaLink') + ->assertDispatched('success'); + + $invitation = TeamInvitation::whereEmail('fqdn-invitee@example.com')->firstOrFail(); + + expect($invitation->link)->toStartWith('https://coolify.example.com/auth/link?token='); + }); + + test('new user invitation magic link falls back to route url when instance fqdn is not configured', function () { + InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate( + ['id' => 0], + ['fqdn' => null] + )); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); + + Livewire::test(InviteLink::class) + ->set('email', 'fallback-invitee@example.com') + ->set('role', 'member') + ->call('viaLink') + ->assertDispatched('success'); + + $invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail(); + + expect($invitation->link)->toStartWith('http://localhost/auth/link?token='); + }); + test('member cannot bypass policy by calling viaEmail', function () { // Login as member $this->actingAs($this->member); From 45d9426690957ff8da2f65cb4f3b0ec5d1bc4dea Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 15 Jun 2026 11:55:03 +0200 Subject: [PATCH 2/4] test(auth): expect invitation link to use auth.link route --- tests/Feature/TeamInvitationPrivilegeEscalationTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/TeamInvitationPrivilegeEscalationTest.php b/tests/Feature/TeamInvitationPrivilegeEscalationTest.php index af8a57cdd..06013935f 100644 --- a/tests/Feature/TeamInvitationPrivilegeEscalationTest.php +++ b/tests/Feature/TeamInvitationPrivilegeEscalationTest.php @@ -202,7 +202,8 @@ $invitation = TeamInvitation::whereEmail('fallback-invitee@example.com')->firstOrFail(); - expect($invitation->link)->toStartWith('http://localhost/auth/link?token='); + $expectedPrefix = route('auth.link', ['token' => '']); + expect($invitation->link)->toStartWith($expectedPrefix); }); test('member cannot bypass policy by calling viaEmail', function () { From bec871cf78d07c647ee97b7a06cf08afb82838c8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:56:57 +0200 Subject: [PATCH 3/4] fix(team): keep invite link root element --- .../views/livewire/team/invite-link.blade.php | 40 ++++++++++--------- templates/service-templates-latest.json | 6 +-- templates/service-templates.json | 6 +-- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/resources/views/livewire/team/invite-link.blade.php b/resources/views/livewire/team/invite-link.blade.php index a6c2fb9ec..7960443fe 100644 --- a/resources/views/livewire/team/invite-link.blade.php +++ b/resources/views/livewire/team/invite-link.blade.php @@ -1,20 +1,22 @@ -@can('manageInvitations', currentTeam()) -
-
- - - @if (auth()->user()->role() === 'owner') - +
+ @can('manageInvitations', currentTeam()) + +
+ + + @if (auth()->user()->role() === 'owner') + + @endif + + + +
+
+ Generate Invitation Link + @if (is_transactional_emails_enabled()) + Send Invitation via Email @endif - - - -
-
- Generate Invitation Link - @if (is_transactional_emails_enabled()) - Send Invitation via Email - @endif -
- -@endcan +
+ + @endcan +
diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 8d1a3369b..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { diff --git a/templates/service-templates.json b/templates/service-templates.json index 2ad3008f5..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-05-09T19:26:30+05:30", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-06-01T07:54:27-05:00" + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { From c0866d4cb33e398f19c7c1c7f4720fe8c98e2cb4 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:38:05 +0200 Subject: [PATCH 4/4] fix(auth): preserve invite login with database sessions --- app/Http/Controllers/Controller.php | 3 ++- tests/Feature/InvitationLinkHandlingTest.php | 22 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index a567c6244..c723d811a 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -138,10 +138,11 @@ public function link() } $invitation->delete(); - Auth::login($user); $user->forceFill([ 'password' => Hash::make(Str::random(64)), ])->save(); + + Auth::login($user); session(['currentTeam' => $team]); return redirect()->route('dashboard'); diff --git a/tests/Feature/InvitationLinkHandlingTest.php b/tests/Feature/InvitationLinkHandlingTest.php index 184dfd77f..3b473f60e 100644 --- a/tests/Feature/InvitationLinkHandlingTest.php +++ b/tests/Feature/InvitationLinkHandlingTest.php @@ -9,6 +9,7 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Once; use Visus\Cuid2\Cuid2; @@ -91,6 +92,27 @@ function createInvitationLinkFixture(array $invitationAttributes = []): array expect(Hash::check($password, $user->password))->toBeFalse(); }); +it('keeps the invited user authenticated after rotating the temporary password with database sessions', function () { + $this->withMiddleware([CheckForcePasswordReset::class, DecideWhatToDoWithUser::class]); + Config::set('session.driver', 'database'); + + [$team, $user, $password, $token] = createInvitationLinkFixture(); + + $this->get(route('auth.link', ['token' => $token])) + ->assertRedirect(route('dashboard')); + + expect(DB::table('sessions')->where('user_id', $user->id)->exists())->toBeTrue(); + + $this->get(route('dashboard')) + ->assertRedirect(route('auth.force-password-reset')); + + $this->assertAuthenticatedAs($user); + expect($user->teams()->where('team_id', $team->id)->exists())->toBeTrue(); + + $user->refresh(); + expect(Hash::check($password, $user->password))->toBeFalse(); +}); + it('rejects a magic link when the stored invitation token differs', function () { [, $user, , $token, $invitation] = createInvitationLinkFixture(); $differentToken = Crypt::encryptString("{$user->email}@@@{$invitation->uuid}@@@different-password");