From fc32bc6b6c9d260d44ea3e623475c255233fc0f3 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:13:39 +0200 Subject: [PATCH] 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. --- app/Livewire/SettingsOauth.php | 3 ++ app/Models/OauthSetting.php | 3 +- app/Services/Auth/OauthLoginService.php | 30 ++++++++++++++-- ...join_root_team_to_oauth_settings_table.php | 28 +++++++++++++++ .../views/livewire/settings-oauth.blade.php | 8 +++++ tests/Feature/OidcOauthControllerTest.php | 34 +++++++++++++++++++ tests/Feature/SettingsOauthTest.php | 10 ++++-- 7 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php diff --git a/app/Livewire/SettingsOauth.php b/app/Livewire/SettingsOauth.php index d038d9a95..a3ee2c987 100644 --- a/app/Livewire/SettingsOauth.php +++ b/app/Livewire/SettingsOauth.php @@ -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, diff --git a/app/Models/OauthSetting.php b/app/Models/OauthSetting.php index 25e38b4d1..bc6fc06c1 100644 --- a/app/Models/OauthSetting.php +++ b/app/Models/OauthSetting.php @@ -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', diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index f1c3b4d82..f656fad08 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -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; + }); + } } diff --git a/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php new file mode 100644 index 000000000..b0f5aad18 --- /dev/null +++ b/database/migrations/2026_06_23_151229_add_auto_join_root_team_to_oauth_settings_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/views/livewire/settings-oauth.blade.php b/resources/views/livewire/settings-oauth.blade.php index 8308e88d0..1ff75512e 100644 --- a/resources/views/livewire/settings-oauth.blade.php +++ b/resources/views/livewire/settings-oauth.blade.php @@ -136,6 +136,14 @@ class="menu-item-label">{{ $oauth_setting['label'] }} @endif +
+
+ +
+
@endif diff --git a/tests/Feature/OidcOauthControllerTest.php b/tests/Feature/OidcOauthControllerTest.php index 6ea861ec1..21b335016 100644 --- a/tests/Feature/OidcOauthControllerTest.php +++ b/tests/Feature/OidcOauthControllerTest.php @@ -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']); diff --git a/tests/Feature/SettingsOauthTest.php b/tests/Feature/SettingsOauthTest.php index 0d25ce0f3..e55cdac4f 100644 --- a/tests/Feature/SettingsOauthTest.php +++ b/tests/Feature/SettingsOauthTest.php @@ -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(); });