feat(auth): auto-join OAuth users to root team

Add an OAuth setting that provisions newly registered users as Root
team members without creating a personal team.
This commit is contained in:
Andras Bacsai 2026-07-02 15:13:39 +02:00
parent c9264dde2e
commit fc32bc6b6c
7 changed files with 110 additions and 6 deletions

View file

@ -42,6 +42,7 @@ private function validationRules(?string $provider = null): array
$carry["oauth_settings_map.$setting->provider.custom_label"] = 'nullable|string|max:255';
$carry["oauth_settings_map.$setting->provider.scopes"] = 'nullable|string|max:1000';
$carry["oauth_settings_map.$setting->provider.allow_registration"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.auto_join_root_team"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.require_email_verified"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.use_pkce"] = 'boolean';
$carry["oauth_settings_map.$setting->provider.clock_skew_seconds"] = 'nullable|integer|min:0|max:600';
@ -148,6 +149,7 @@ private function fillOauthSetting(OauthSetting $oauth, array $data): void
'custom_label' => $data['custom_label'] ?? null,
'scopes' => $data['scopes'] ?? null,
'allow_registration' => (bool) ($data['allow_registration'] ?? false),
'auto_join_root_team' => (bool) ($data['auto_join_root_team'] ?? false),
'require_email_verified' => (bool) ($data['require_email_verified'] ?? true),
'use_pkce' => (bool) ($data['use_pkce'] ?? true),
'clock_skew_seconds' => (int) ($data['clock_skew_seconds'] ?? 60),
@ -196,6 +198,7 @@ private function oauthSettingToArray(OauthSetting $setting): array
'custom_label' => $setting->custom_label,
'scopes' => $setting->scopes ?: 'openid email profile',
'allow_registration' => $setting->allow_registration,
'auto_join_root_team' => $setting->auto_join_root_team,
'require_email_verified' => $setting->require_email_verified ?? true,
'use_pkce' => $setting->use_pkce ?? true,
'clock_skew_seconds' => $setting->clock_skew_seconds ?? 60,

View file

@ -11,13 +11,14 @@ class OauthSetting extends Model
{
use HasFactory;
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
protected $fillable = ['provider', 'client_id', 'client_secret', 'redirect_uri', 'tenant', 'base_url', 'enabled', 'custom_label', 'scopes', 'allow_registration', 'auto_join_root_team', 'require_email_verified', 'use_pkce', 'clock_skew_seconds'];
protected function casts(): array
{
return [
'enabled' => 'boolean',
'allow_registration' => 'boolean',
'auto_join_root_team' => 'boolean',
'require_email_verified' => 'boolean',
'use_pkce' => 'boolean',
'clock_skew_seconds' => 'integer',

View file

@ -44,7 +44,7 @@ private function resolveOauthUser(object $oauthUser, OauthSetting $oauthSetting,
throw new HttpException(403, 'Registration is disabled');
}
return $this->createUser($oauthUser->name ?: $email, $email);
return $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
}
private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting, string $email): User
@ -100,7 +100,7 @@ private function resolveOidcUser(object $oauthUser, OauthSetting $oauthSetting,
throw new HttpException(403, 'Registration is disabled');
}
$user = $this->createUser($oauthUser->name ?: $email, $email);
$user = $this->createUser($oauthUser->name ?: $email, $email, $oauthSetting);
}
OauthIdentity::create([
@ -122,7 +122,7 @@ private function canCreateUser(OauthSetting $oauthSetting): bool
return instanceSettings()->is_registration_enabled || $oauthSetting->allow_registration;
}
private function createUser(string $name, string $email): User
private function createUser(string $name, string $email, OauthSetting $oauthSetting): User
{
if (User::count() === 0) {
$user = (new User)->forceFill([
@ -143,10 +143,34 @@ private function createUser(string $name, string $email): User
return $user;
}
if ($oauthSetting->auto_join_root_team) {
return $this->createRootTeamOnlyUser($name, $email);
}
return User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
]);
}
private function createRootTeamOnlyUser(string $name, string $email): User
{
return DB::transaction(function () use ($name, $email) {
$rootTeam = Team::find(0);
if ($rootTeam === null) {
throw new HttpException(403, 'Root team is not available for OAuth user provisioning');
}
$user = User::withoutEvents(fn () => User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make(Str::random(64)),
]));
$user->teams()->attach($rootTeam, ['role' => 'member']);
return $user;
});
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->boolean('auto_join_root_team')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('oauth_settings', function (Blueprint $table) {
$table->dropColumn('auto_join_root_team');
});
}
};

View file

@ -136,6 +136,14 @@ class="menu-item-label">{{ $oauth_setting['label'] }}</span></a>
</div>
</div>
@endif
<div class="flex flex-col gap-2 pt-2">
<div class="md:w-96">
<x-forms.checkbox canGate="update" :canResource="$settings"
id="oauth_settings_map.{{ $oauth_setting['provider'] }}.auto_join_root_team"
label="Auto-join new users to Root team"
helper="When enabled, newly-created OAuth users are added to the Root team as members and no personal team is created for them." />
</div>
</div>
</div>
</div>
@endif

View file

@ -93,6 +93,40 @@ function fakeOidcProvider(array $claims = []): void
]);
});
it('creates a new oidc user in the root team only when provider root auto-join is enabled', function () {
Team::forceCreate(['id' => 0, 'name' => 'Root Team', 'personal_team' => true]);
(new User)->forceFill([
'id' => 0,
'name' => 'Root User',
'email' => 'root@example.com',
'password' => 'password',
])->save();
OauthSetting::where('provider', 'oidc')->update([
'allow_registration' => true,
'auto_join_root_team' => true,
]);
fakeOidcProvider(['email' => 'root-member@example.com', 'name' => 'Root Member']);
$response = $this->get(route('auth.callback', 'oidc'));
$response->assertRedirect('/');
$user = User::whereEmail('root-member@example.com')->first();
expect($user)->not->toBeNull()
->and($user->teams()->count())->toBe(1);
$rootMembership = $user->teams()->where('teams.id', 0)->first();
expect($rootMembership)->not->toBeNull()
->and($rootMembership->pivot->role)->toBe('member');
$this->assertDatabaseMissing('teams', [
'name' => "Root Member's Team",
]);
expect(session('currentTeam')->id)->toBe(0);
$this->assertAuthenticatedAs($user);
});
it('rejects linking an unverified oidc email to an existing local account', function () {
$user = User::factory()->create(['email' => 'victim@example.com']);

View file

@ -26,6 +26,9 @@ function actingAsInstanceAdmin(): User
}
beforeEach(function () {
$this->withoutVite();
config()->set('app.maintenance.driver', 'file');
InstanceSettings::forceCreate(['id' => 0, 'is_registration_enabled' => true]);
Once::flush();
OauthSetting::create(['provider' => 'oidc']);
@ -124,7 +127,8 @@ function actingAsInstanceAdmin(): User
$setting = OauthSetting::where('provider', 'oidc')->first();
expect($setting->allow_registration)->toBeTrue()
->and($setting->require_email_verified)->toBeTrue();
->and($setting->require_email_verified)->toBeTrue()
->and($setting->auto_join_root_team)->toBeFalse();
});
it('persists oidc oauth settings from livewire', function () {
@ -139,6 +143,7 @@ function actingAsInstanceAdmin(): User
->set('oauth_settings_map.oidc.scopes', 'openid email profile groups')
->set('oauth_settings_map.oidc.custom_label', 'Login with Okta')
->set('oauth_settings_map.oidc.allow_registration', true)
->set('oauth_settings_map.oidc.auto_join_root_team', true)
->set('oauth_settings_map.oidc.require_email_verified', true)
->set('disable_registration_when_oauth_enabled', true)
->call('submit')
@ -150,7 +155,8 @@ function actingAsInstanceAdmin(): User
->and($setting->base_url)->toBe('https://idp.example.com')
->and($setting->custom_label)->toBe('Login with Okta')
->and($setting->scopeList())->toBe(['openid', 'email', 'profile', 'groups'])
->and($setting->allow_registration)->toBeTrue();
->and($setting->allow_registration)->toBeTrue()
->and($setting->auto_join_root_team)->toBeTrue();
expect(instanceSettings()->fresh()->disable_registration_when_oauth_enabled)->toBeTrue();
});