fix(auth): validate invitation magic link tokens

Accept invitation links across configured public origins while still
rejecting stored invitations whose token no longer matches.
This commit is contained in:
Andras Bacsai
2026-06-12 16:17:45 +02:00
parent 442ed98169
commit 4f509c02be
5 changed files with 99 additions and 6 deletions
@@ -77,6 +77,34 @@ it('accepts a valid magic link invitation only once and rotates the temporary pa
$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();
@@ -1,7 +1,9 @@
<?php
use App\Livewire\Team\InviteLink;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@@ -9,6 +11,8 @@ use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->updateOrCreate(['id' => 0], ['fqdn' => null]));
// Create a team with owner, admin, and member
$this->team = Team::factory()->create();
@@ -161,6 +165,46 @@ describe('privilege escalation prevention', function () {
]);
});
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);