From b8bc197b17fb046f31b515ae2e76bd755c11b5db Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 30 Sep 2026 18:10:10 +0200 Subject: [PATCH] fix(oauth): key non-OIDC identities by provider instance GitLab, Authentik, Zitadel, Clerk and Azure identities were keyed by the provider name only, so a user with the same subject on another instance could sign in as the linked user after the base URL or tenant changed. The issuer column now stores the normalized instance, existing rows are backfilled from the current settings, and oauth:rekey handles host renames. --- app/Console/Commands/RekeyOauthIdentities.php | 94 +++++ app/Http/Controllers/OauthController.php | 7 +- app/Services/Auth/OauthIdentityIssuer.php | 97 ++++++ app/Services/Auth/OauthLoginService.php | 13 +- ..._identity_issuers_to_provider_instance.php | 51 +++ tests/Feature/OauthControllerTest.php | 1 + tests/Feature/OauthIdentityInstanceTest.php | 326 ++++++++++++++++++ 7 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/RekeyOauthIdentities.php create mode 100644 app/Services/Auth/OauthIdentityIssuer.php create mode 100644 database/migrations/2026_09_30_120000_rekey_oauth_identity_issuers_to_provider_instance.php create mode 100644 tests/Feature/OauthIdentityInstanceTest.php diff --git a/app/Console/Commands/RekeyOauthIdentities.php b/app/Console/Commands/RekeyOauthIdentities.php new file mode 100644 index 0000000000..d79adae083 --- /dev/null +++ b/app/Console/Commands/RekeyOauthIdentities.php @@ -0,0 +1,94 @@ +argument('provider'); + $from = $this->instanceKey($provider, $this->option('from')); + $to = filled($this->option('to')) + ? $this->instanceKey($provider, $this->option('to')) + : $this->configuredInstanceKey($provider); + + if ($from === null || $to === null) { + $this->error('Could not determine the instance key. Pass --from and, if the provider is not configured, --to.'); + + return self::FAILURE; + } + + if ($from === $to) { + $this->info('The previous and new instance keys are the same.'); + + return self::SUCCESS; + } + + $identities = OauthIdentity::query()->where('provider', $provider)->where('issuer', $from); + $count = $identities->count(); + if ($count === 0) { + $this->info("No {$provider} identities use {$from}."); + + return self::SUCCESS; + } + + $question = sprintf('Move %d %s %s from %s to %s?', $count, $provider, Str::plural('identity', $count), $from, $to); + if (! $this->option('force') && ! $this->confirm($question)) { + return self::FAILURE; + } + + $conflicts = OauthIdentity::query() + ->where('provider', $provider) + ->where('issuer', $to) + ->whereIn('provider_user_id', (clone $identities)->select('provider_user_id')) + ->count(); + if ($conflicts > 0) { + $this->error("{$conflicts} identities already exist under {$to}. Nothing was changed."); + + return self::FAILURE; + } + + $identities->update(['issuer' => $to]); + $this->info("Moved {$count} {$provider} ".Str::plural('identity', $count)." to {$to}."); + + return self::SUCCESS; + } + + private function instanceKey(string $provider, mixed $value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + if ($provider === 'oidc') { + return $value; + } + + return OauthIdentityIssuer::forProvider($provider, $value, $value); + } + + private function configuredInstanceKey(string $provider): ?string + { + $oauthSetting = OauthSetting::query()->where('provider', $provider)->first(); + if ($oauthSetting === null || $provider === 'oidc') { + return null; + } + + return OauthIdentityIssuer::forSetting($oauthSetting); + } +} diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php index 6d0c57f4df..c28290919a 100644 --- a/app/Http/Controllers/OauthController.php +++ b/app/Http/Controllers/OauthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\OauthSetting; +use App\Services\Auth\OauthIdentityIssuer; use App\Services\Auth\OauthLoginService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -29,7 +30,11 @@ class OauthController extends Controller { $user = $request->user(); $oauthSetting = $this->enabledProvider($provider); - if (! $user->oauthIdentities()->where('provider', $oauthSetting->provider)->exists()) { + $linkedIdentities = $user->oauthIdentities()->where('provider', $oauthSetting->provider); + if (! $oauthSetting->isOidc()) { + $linkedIdentities->where('issuer', OauthIdentityIssuer::forSetting($oauthSetting)); + } + if (! $linkedIdentities->exists()) { abort(403, 'No identity from this provider is linked to your account.'); } diff --git a/app/Services/Auth/OauthIdentityIssuer.php b/app/Services/Auth/OauthIdentityIssuer.php new file mode 100644 index 0000000000..ab8ed909b8 --- /dev/null +++ b/app/Services/Auth/OauthIdentityIssuer.php @@ -0,0 +1,97 @@ + + */ + public const array SELF_HOSTABLE_PROVIDERS = [ + 'authentik' => null, + 'clerk' => null, + 'gitlab' => 'https://gitlab.com', + 'zitadel' => null, + ]; + + /** + * Azure endpoints that accept users from any tenant. Object ids are + * globally unique GUIDs there, so they share one stable key. + * + * @var array + */ + public const array AZURE_MULTI_TENANT_ENDPOINTS = ['common', 'organizations', 'consumers']; + + public static function forSetting(OauthSetting $oauthSetting): ?string + { + return self::forProvider($oauthSetting->provider, $oauthSetting->base_url, $oauthSetting->tenant); + } + + /** + * Returns null for OIDC and for self-hostable providers without a usable + * base URL. + */ + public static function forProvider(string $provider, ?string $baseUrl, ?string $tenant): ?string + { + if ($provider === 'oidc') { + return null; + } + + if (array_key_exists($provider, self::SELF_HOSTABLE_PROVIDERS)) { + $baseUrl = trim((string) $baseUrl); + + return self::normalizeBaseUrl($baseUrl !== '' ? $baseUrl : self::SELF_HOSTABLE_PROVIDERS[$provider]); + } + + if ($provider === 'azure') { + $tenant = strtolower(trim((string) $tenant)); + + return $tenant === '' || in_array($tenant, self::AZURE_MULTI_TENANT_ENDPOINTS, true) ? 'azure' : $tenant; + } + + return $provider; + } + + /** + * Lowercases scheme and host, drops default ports, credentials, query and + * fragment, and keeps the path without a trailing slash. + */ + public static function normalizeBaseUrl(?string $baseUrl): ?string + { + $baseUrl = trim((string) $baseUrl); + if ($baseUrl === '') { + return null; + } + + if (! str_contains($baseUrl, '://')) { + $baseUrl = 'https://'.$baseUrl; + } + + $parts = parse_url($baseUrl); + if ($parts === false || blank($parts['host'] ?? null) || blank($parts['scheme'] ?? null)) { + return null; + } + + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $port = $parts['port'] ?? null; + if (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80)) { + $port = null; + } + $path = rtrim($parts['path'] ?? '', '/'); + + return $scheme.'://'.$host.($port !== null ? ':'.$port : '').$path; + } +} diff --git a/app/Services/Auth/OauthLoginService.php b/app/Services/Auth/OauthLoginService.php index b9e5e82a21..eece071e02 100644 --- a/app/Services/Auth/OauthLoginService.php +++ b/app/Services/Auth/OauthLoginService.php @@ -83,7 +83,7 @@ class OauthLoginService [$issuer, $subject] = $this->oidcIssuerAndSubject($oauthUser); $providerUserId = $subject; } else { - $issuer = $oauthSetting->provider; + $issuer = OauthIdentityIssuer::forSetting($oauthSetting); $providerUserId = $oauthUser->id ?? null; if (is_int($providerUserId)) { $providerUserId = (string) $providerUserId; @@ -133,14 +133,19 @@ class OauthLoginService throw new HttpException(403, 'Google account is not in the configured Workspace'); } + $issuer = OauthIdentityIssuer::forSetting($oauthSetting); + if ($issuer === null) { + throw new HttpException(403, 'OAuth provider instance is not configured'); + } + $identityKey = [ 'provider' => $provider, - 'issuer' => $provider, + 'issuer' => $issuer, 'provider_user_id' => $providerUserId, ]; try { - return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $providerUserId, $rawClaims, $identityKey): User { + return DB::transaction(function () use ($oauthUser, $oauthSetting, $email, $provider, $issuer, $providerUserId, $rawClaims, $identityKey): User { $identity = OauthIdentity::where($identityKey)->first(); if ($identity) { @@ -176,7 +181,7 @@ class OauthLoginService OauthIdentity::create([ 'user_id' => $user->id, 'provider' => $provider, - 'issuer' => $provider, + 'issuer' => $issuer, 'provider_user_id' => $providerUserId, 'email' => $email, 'raw_claims' => $rawClaims, diff --git a/database/migrations/2026_09_30_120000_rekey_oauth_identity_issuers_to_provider_instance.php b/database/migrations/2026_09_30_120000_rekey_oauth_identity_issuers_to_provider_instance.php new file mode 100644 index 0000000000..8cc317b323 --- /dev/null +++ b/database/migrations/2026_09_30_120000_rekey_oauth_identity_issuers_to_provider_instance.php @@ -0,0 +1,51 @@ + --from=`. + */ + public function up(): void + { + $oauthSettings = DB::table('oauth_settings') + ->where('provider', '!=', 'oidc') + ->orderBy('id') + ->get(['provider', 'base_url', 'tenant']) + ->unique('provider'); + + foreach ($oauthSettings as $oauthSetting) { + $issuer = OauthIdentityIssuer::forProvider($oauthSetting->provider, $oauthSetting->base_url, $oauthSetting->tenant); + if ($issuer === null || $issuer === $oauthSetting->provider) { + continue; + } + + DB::table('oauth_identities') + ->where('provider', $oauthSetting->provider) + ->where('issuer', $oauthSetting->provider) + ->update(['issuer' => $issuer]); + } + } + + public function down(): void + { + DB::table('oauth_identities') + ->where('provider', '!=', 'oidc') + ->update(['issuer' => DB::raw('provider')]); + } +}; diff --git a/tests/Feature/OauthControllerTest.php b/tests/Feature/OauthControllerTest.php index 5ca0d20168..306c048be3 100644 --- a/tests/Feature/OauthControllerTest.php +++ b/tests/Feature/OauthControllerTest.php @@ -278,6 +278,7 @@ it('links a user from before the upgrade without an email verification claim', f $setting = OauthSetting::updateOrCreate(['provider' => $provider], [ 'client_id' => 'client-id', 'client_secret' => 'client-secret', + 'base_url' => 'https://auth.example.com', 'enabled' => true, ]); diff --git a/tests/Feature/OauthIdentityInstanceTest.php b/tests/Feature/OauthIdentityInstanceTest.php new file mode 100644 index 0000000000..a237aac5fd --- /dev/null +++ b/tests/Feature/OauthIdentityInstanceTest.php @@ -0,0 +1,326 @@ + 0, + 'is_registration_enabled' => false, + ]); + + Once::flush(); +}); + +function instanceOauthSetting(string $provider, array $attributes = []): OauthSetting +{ + return OauthSetting::create([ + 'provider' => $provider, + 'client_id' => "{$provider}-client-id", + 'client_secret' => "{$provider}-client-secret", + 'enabled' => true, + ...$attributes, + ]); +} + +function linkInstanceIdentity(User $user, string $provider, string $issuer, string $providerUserId): OauthIdentity +{ + return OauthIdentity::create([ + 'user_id' => $user->id, + 'provider' => $provider, + 'issuer' => $issuer, + 'provider_user_id' => $providerUserId, + 'email' => $user->email, + ]); +} + +function instanceOauthUser(string|int $id, string $email, array $rawClaims = []): object +{ + return (object) [ + 'id' => $id, + 'email' => $email, + 'name' => 'Provider User', + 'user' => $rawClaims, + ]; +} + +describe('instance key', function () { + it('normalizes the base URL of self-hostable providers', function (string $provider, ?string $baseUrl, ?string $expected) { + expect(OauthIdentityIssuer::forProvider($provider, $baseUrl, null))->toBe($expected); + })->with([ + 'gitlab.com default' => ['gitlab', null, 'https://gitlab.com'], + 'gitlab empty base url' => ['gitlab', '', 'https://gitlab.com'], + 'trailing slash' => ['gitlab', 'https://gitlab.example.com/', 'https://gitlab.example.com'], + 'uppercase scheme and host' => ['gitlab', 'HTTPS://GitLab.Example.COM', 'https://gitlab.example.com'], + 'default https port' => ['authentik', 'https://auth.example.com:443/', 'https://auth.example.com'], + 'default http port' => ['authentik', 'http://auth.example.com:80', 'http://auth.example.com'], + 'custom port kept' => ['authentik', 'https://auth.example.com:8443', 'https://auth.example.com:8443'], + 'path kept without trailing slash' => ['gitlab', 'https://example.com/GitLab/', 'https://example.com/GitLab'], + 'query and fragment dropped' => ['zitadel', 'https://id.example.com/?a=b#c', 'https://id.example.com'], + 'missing scheme' => ['clerk', 'clerk.example.com', 'https://clerk.example.com'], + 'authentik without base url' => ['authentik', null, null], + 'zitadel without base url' => ['zitadel', ' ', null], + ]); + + it('uses the lowercased Azure tenant and one stable key for multi-tenant endpoints', function (?string $tenant, string $expected) { + expect(OauthIdentityIssuer::forProvider('azure', null, $tenant))->toBe($expected); + })->with([ + 'tenant id' => ['1F2E3D4C-0000-4000-8000-ABCDEF123456', '1f2e3d4c-0000-4000-8000-abcdef123456'], + 'tenant domain' => [' Contoso.onmicrosoft.com ', 'contoso.onmicrosoft.com'], + 'common' => ['common', 'azure'], + 'organizations' => ['Organizations', 'azure'], + 'consumers' => ['consumers', 'azure'], + 'no tenant' => [null, 'azure'], + ]); + + it('keeps the provider name for single SaaS providers', function (string $provider) { + expect(OauthIdentityIssuer::forProvider($provider, 'https://ignored.example.com', 'ignored.example.com'))->toBe($provider); + })->with(['github', 'bitbucket', 'discord', 'google', 'infomaniak']); + + it('does not compute a key for OIDC, which uses the iss claim', function () { + expect(OauthIdentityIssuer::forProvider('oidc', 'https://idp.example.com', null))->toBeNull(); + }); +}); + +describe('login', function () { + it('does not log in as the linked user when the same subject comes from another instance', function (string $provider, ?string $linkedBaseUrl, string $otherBaseUrl) { + $victim = User::factory()->create(['email' => 'victim@example.com']); + $setting = instanceOauthSetting($provider, ['base_url' => $linkedBaseUrl]); + $verifiedClaims = ['confirmed_at' => '2026-01-01T00:00:00Z', 'email_verified' => true]; + $service = app(OauthLoginService::class); + + expect($service->login($provider, instanceOauthUser(1, 'victim@example.com', $verifiedClaims), $setting)->is($victim))->toBeTrue(); + Auth::logout(); + + $setting->update(['base_url' => $otherBaseUrl]); + + expect(fn () => $service->login($provider, instanceOauthUser(1, 'attacker@example.com', $verifiedClaims), $setting->refresh())) + ->toThrow(HttpException::class); + + expect(Auth::check())->toBeFalse() + ->and(OauthIdentity::count())->toBe(1); + })->with([ + 'self-hosted gitlab' => ['gitlab', 'https://gitlab.example.com', 'https://gitlab.attacker.example'], + 'gitlab.com to self-hosted' => ['gitlab', null, 'https://gitlab.attacker.example'], + 'authentik' => ['authentik', 'https://auth.example.com', 'https://auth.attacker.example'], + 'zitadel' => ['zitadel', 'https://id.example.com', 'https://id.attacker.example'], + 'clerk' => ['clerk', 'https://clerk.example.com', 'https://clerk.attacker.example'], + ]); + + it('does not log in as the linked user with the same subject and email from another instance', function () { + $victim = User::factory()->create(['email' => 'victim@example.com']); + $setting = instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.example.com']); + $claims = ['confirmed_at' => '2026-01-01T00:00:00Z']; + $service = app(OauthLoginService::class); + + $service->login('gitlab', instanceOauthUser(1, 'victim@example.com', $claims), $setting); + Auth::logout(); + $setting->update(['base_url' => 'https://gitlab.attacker.example']); + + expect(fn () => $service->login('gitlab', instanceOauthUser(1, 'victim@example.com', $claims), $setting->refresh())) + ->toThrow(HttpException::class); + + expect(Auth::check())->toBeFalse(); + }); + + it('logs in the linked user from the same instance when the base URL is written differently', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + linkInstanceIdentity($user, 'gitlab', 'https://gitlab.example.com', '1'); + $setting = instanceOauthSetting('gitlab', ['base_url' => 'HTTPS://GitLab.Example.com:443/']); + + $resolvedUser = app(OauthLoginService::class)->login('gitlab', instanceOauthUser(1, 'user@example.com'), $setting); + + expect($resolvedUser->is($user))->toBeTrue(); + $this->assertAuthenticatedAs($user); + }); + + it('does not log in as the linked user after the Azure tenant changes', function () { + $user = User::factory()->create(['email' => 'user@contoso.com']); + $setting = instanceOauthSetting('azure', ['tenant' => 'contoso-tenant']); + $azureUser = instanceOauthUser('azure-object-id', 'user@contoso.com', ['userPrincipalName' => 'user@contoso.com']); + $service = app(OauthLoginService::class); + + $service->login('azure', $azureUser, $setting); + Auth::logout(); + $setting->update(['tenant' => 'other-tenant']); + + expect(fn () => $service->login('azure', $azureUser, $setting->refresh()))->toThrow(HttpException::class); + + expect(Auth::check())->toBeFalse(); + }); + + it('logs in the linked Azure user from the same tenant', function () { + $user = User::factory()->create(['email' => 'user@contoso.com']); + linkInstanceIdentity($user, 'azure', 'contoso-tenant', 'azure-object-id'); + $setting = instanceOauthSetting('azure', ['tenant' => 'Contoso-Tenant']); + + $resolvedUser = app(OauthLoginService::class)->login('azure', instanceOauthUser('azure-object-id', 'user@contoso.com'), $setting); + + expect($resolvedUser->is($user))->toBeTrue(); + }); + + it('stores new identities under the normalized instance key', function () { + instanceSettings()->update(['is_registration_enabled' => true]); + $setting = instanceOauthSetting('gitlab', ['base_url' => 'https://GitLab.Example.com/']); + + $user = app(OauthLoginService::class)->login('gitlab', instanceOauthUser(7, 'new@example.com', [ + 'confirmed_at' => '2026-01-01T00:00:00Z', + ]), $setting); + + $this->assertDatabaseHas('oauth_identities', [ + 'user_id' => $user->id, + 'provider' => 'gitlab', + 'issuer' => 'https://gitlab.example.com', + 'provider_user_id' => '7', + ]); + }); + + it('keeps the provider name as the key for single SaaS providers', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + linkInstanceIdentity($user, 'github', 'github', '99'); + $setting = instanceOauthSetting('github'); + + $resolvedUser = app(OauthLoginService::class)->login('github', instanceOauthUser(99, 'user@example.com'), $setting); + + expect($resolvedUser->is($user))->toBeTrue() + ->and(OauthIdentity::sole()->issuer)->toBe('github'); + }); + + it('rejects login when a self-hostable provider has no base URL', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + linkInstanceIdentity($user, 'authentik', 'authentik', 'subject'); + $setting = instanceOauthSetting('authentik'); + + expect(fn () => app(OauthLoginService::class)->login('authentik', instanceOauthUser('subject', 'user@example.com'), $setting)) + ->toThrow(HttpException::class); + + expect(Auth::check())->toBeFalse(); + }); +}); + +describe('confirmation', function () { + it('matches the linked identity only on the same instance', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + $setting = instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.example.com/']); + $service = app(OauthLoginService::class); + $service->login('gitlab', instanceOauthUser(1, 'user@example.com', ['confirmed_at' => '2026-01-01T00:00:00Z']), $setting); + + expect($service->identityBelongsToUser($user, $setting, instanceOauthUser(1, 'user@example.com')))->toBeTrue(); + + $setting->update(['base_url' => 'https://gitlab.attacker.example']); + + expect($service->identityBelongsToUser($user, $setting->refresh(), instanceOauthUser(1, 'user@example.com')))->toBeFalse(); + }); + + it('does not start a confirmation without an identity from the configured instance', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + linkInstanceIdentity($user, 'gitlab', 'https://gitlab.example.com', '1'); + instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.attacker.example']); + + $this->actingAs($user)->get(route('auth.confirm', 'gitlab'))->assertForbidden(); + }); +}); + +describe('backfill migration', function () { + it('rekeys non-OIDC identities to the currently configured instance', function () { + $user = User::factory()->create(); + instanceOauthSetting('gitlab', ['base_url' => 'https://GitLab.Example.com:443/']); + instanceOauthSetting('authentik', ['base_url' => 'https://auth.example.com/']); + instanceOauthSetting('azure', ['tenant' => 'Contoso-Tenant']); + instanceOauthSetting('github'); + instanceOauthSetting('oidc', ['base_url' => 'https://idp.example.com']); + + $gitlab = linkInstanceIdentity($user, 'gitlab', 'gitlab', '1'); + $authentik = linkInstanceIdentity($user, 'authentik', 'authentik', '2'); + $azure = linkInstanceIdentity($user, 'azure', 'azure', '3'); + $github = linkInstanceIdentity($user, 'github', 'github', '4'); + $oidc = linkInstanceIdentity($user, 'oidc', 'https://idp.example.com', '5'); + + $migration = require database_path(OAUTH_REKEY_MIGRATION); + $migration->up(); + + expect($gitlab->refresh()->issuer)->toBe('https://gitlab.example.com') + ->and($authentik->refresh()->issuer)->toBe('https://auth.example.com') + ->and($azure->refresh()->issuer)->toBe('contoso-tenant') + ->and($github->refresh()->issuer)->toBe('github') + ->and($oidc->refresh()->issuer)->toBe('https://idp.example.com'); + }); + + it('defaults GitLab identities to gitlab.com and leaves unconfigured providers untouched', function () { + $user = User::factory()->create(); + instanceOauthSetting('gitlab'); + instanceOauthSetting('zitadel'); + + $gitlab = linkInstanceIdentity($user, 'gitlab', 'gitlab', '1'); + $zitadel = linkInstanceIdentity($user, 'zitadel', 'zitadel', '2'); + + $migration = require database_path(OAUTH_REKEY_MIGRATION); + $migration->up(); + + expect($gitlab->refresh()->issuer)->toBe('https://gitlab.com') + ->and($zitadel->refresh()->issuer)->toBe('zitadel'); + }); + + it('keeps existing logins working after the backfill', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + $setting = instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.example.com']); + linkInstanceIdentity($user, 'gitlab', 'gitlab', '1'); + + $migration = require database_path(OAUTH_REKEY_MIGRATION); + $migration->up(); + + $resolvedUser = app(OauthLoginService::class)->login('gitlab', instanceOauthUser(1, 'user@example.com'), $setting); + + expect($resolvedUser->is($user))->toBeTrue(); + }); +}); + +describe('rekey command', function () { + it('moves identities from an old instance key to the current one', function () { + $user = User::factory()->create(['email' => 'user@example.com']); + instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.new.example']); + $identity = linkInstanceIdentity($user, 'gitlab', 'https://gitlab.old.example', '1'); + $other = linkInstanceIdentity(User::factory()->create(), 'authentik', 'https://gitlab.old.example', '1'); + + $this->artisan('oauth:rekey', ['provider' => 'gitlab', '--from' => 'https://GitLab.old.example/', '--force' => true]) + ->assertSuccessful(); + + expect($identity->refresh()->issuer)->toBe('https://gitlab.new.example') + ->and($other->refresh()->issuer)->toBe('https://gitlab.old.example'); + }); + + it('accepts an explicit target key', function () { + $user = User::factory()->create(); + instanceOauthSetting('azure', ['tenant' => 'new-tenant']); + $identity = linkInstanceIdentity($user, 'azure', 'old-tenant', '1'); + + $this->artisan('oauth:rekey', ['provider' => 'azure', '--from' => 'Old-Tenant', '--to' => 'Other-Tenant', '--force' => true]) + ->assertSuccessful(); + + expect($identity->refresh()->issuer)->toBe('other-tenant'); + }); + + it('does not rekey without confirmation', function () { + $user = User::factory()->create(); + instanceOauthSetting('gitlab', ['base_url' => 'https://gitlab.new.example']); + $identity = linkInstanceIdentity($user, 'gitlab', 'https://gitlab.old.example', '1'); + + $this->artisan('oauth:rekey', ['provider' => 'gitlab', '--from' => 'https://gitlab.old.example']) + ->expectsConfirmation('Move 1 gitlab identity from https://gitlab.old.example to https://gitlab.new.example?', 'no') + ->assertFailed(); + + expect($identity->refresh()->issuer)->toBe('https://gitlab.old.example'); + }); +});