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.
This commit is contained in:
Andras Bacsai committed 2026-09-30 18:10:10 +02:00
1 parent 5921fb2958
commit b8bc197b17
7 files changed
+584 -5

No files matched your search

@@ -0,0 +1,94 @@
<?php
namespace App\Console\Commands;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Services\Auth\OauthIdentityIssuer;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class RekeyOauthIdentities extends Command
{
protected $signature = 'oauth:rekey
{provider : The OAuth provider, e.g. gitlab or azure}
{--from= : The previous base URL, tenant, or OIDC issuer}
{--to= : The new base URL, tenant, or OIDC issuer (defaults to the configured instance)}
{--force : Rekey without asking for confirmation}';
protected $description = 'Move linked OAuth identities to a new provider instance key after the same instance was renamed';
public function handle(): int
{
$provider = (string) $this->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);
}
}
+6 -1
View File
@@ -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.');
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Auth;
use App\Models\OauthSetting;
/**
* Computes the instance key stored in oauth_identities.issuer for non-OIDC
* providers. Subject ids are only unique within one provider instance, so an
* identity must be bound to the instance the provider is configured for:
* the normalized base URL for self-hostable providers, the tenant for Azure,
* and the provider name for providers that only exist as one SaaS instance.
* OIDC identities use the verified iss claim instead.
*/
final class OauthIdentityIssuer
{
/**
* Self-hostable providers and the base URL used when none is configured.
*
* @var array<string, string|null>
*/
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<int, string>
*/
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;
}
}
+9 -4
View File
@@ -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,
@@ -0,0 +1,51 @@
<?php
use App\Services\Auth\OauthIdentityIssuer;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Non-OIDC identities were keyed by the provider name only, so after a
* provider's base URL (or Azure tenant) pointed to another instance, a user
* there with the same subject id signed in as the linked user. Identities
* are now keyed by the provider instance (see OauthIdentityIssuer).
*
* Existing rows were created against the currently configured instance, so
* they are rekeyed with the instance key of the current OAuth settings and
* keep matching. Rows of self-hostable providers without a base URL cannot
* be keyed and stay unchanged; they no longer match any login.
*
* Renaming the host of the same instance (or switching the Azure tenant
* between its GUID and domain) changes the key. Linked users then no longer
* match until an admin runs `php artisan oauth:rekey <provider> --from=<old>`.
*/
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')]);
}
};
+1
View File
@@ -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,
]);
+326
View File
@@ -0,0 +1,326 @@
<?php
use App\Models\InstanceSettings;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\User;
use App\Services\Auth\OauthIdentityIssuer;
use App\Services\Auth\OauthLoginService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Once;
use Symfony\Component\HttpKernel\Exception\HttpException;
uses(RefreshDatabase::class);
const OAUTH_REKEY_MIGRATION = 'migrations/2026_09_30_120000_rekey_oauth_identity_issuers_to_provider_instance.php';
beforeEach(function () {
InstanceSettings::forceCreate([
'id' => 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');
});
});