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'); + }); +});