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:
parent
c9264dde2e
commit
fc32bc6b6c
7 changed files with 110 additions and 6 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue