Files
reservas/tests/Feature/MicrosoftAuthenticationTest.php

274 lines
9.6 KiB
PHP

<?php
use App\Domain\Audit\Models\AuditLog;
use App\Domain\Identity\Models\ExternalIdentity;
use App\Models\User;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
beforeEach(function () {
Cache::clear();
configureMicrosoft();
});
test('microsoft login is hidden until the integration is fully configured', function () {
config()->set('services.microsoft.enabled', false);
$this->get(route('login'))
->assertOk()
->assertInertia(fn ($page) => $page->where('microsoftEnabled', false));
});
test('authorization uses tenant-specific oidc discovery state nonce and pkce', function () {
fakeMicrosoftMetadata();
$response = $this->get(route('auth.microsoft.redirect'));
$response->assertRedirectContains('/oauth2/v2.0/authorize?');
$query = parse_url((string) $response->headers->get('Location'), PHP_URL_QUERY);
parse_str((string) $query, $parameters);
expect($parameters)
->toMatchArray([
'client_id' => microsoftClientId(),
'response_type' => 'code',
'scope' => 'openid profile email',
'code_challenge_method' => 'S256',
])
->and($parameters['state'])->toHaveLength(64)
->and($parameters['nonce'])->toHaveLength(64)
->and($parameters['code_challenge'])->not->toBeEmpty();
$response->assertSessionHas('microsoft_oidc.intent', 'login');
$response->assertSessionHas('microsoft_oidc.state', $parameters['state']);
});
test('an authenticated user can explicitly link a microsoft identity', function () {
$user = User::factory()->create();
$flow = microsoftFlow();
fakeMicrosoftAuthentication($flow, ['email' => $user->email]);
$response = $this->actingAs($user)
->withSession([
'auth.password_confirmed_at' => time(),
'microsoft_oidc' => [...$flow, 'intent' => 'link', 'user_id' => $user->id],
])
->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code']));
$response->assertRedirect(route('security.edit'));
$this->assertAuthenticatedAs($user);
$this->assertDatabaseHas('external_identities', [
'user_id' => $user->id,
'provider' => 'microsoft',
'provider_subject' => 'subject-123',
'tenant_id' => microsoftTenantId(),
'provider_email' => Str::lower($user->email),
]);
expect(AuditLog::query()->where('event', 'identity.microsoft_linked')->count())->toBe(1);
});
test('starting an identity link requires recent password confirmation', function () {
fakeMicrosoftMetadata();
$user = User::factory()->create();
$this->actingAs($user)
->get(route('identity.microsoft.redirect'))
->assertRedirect(route('password.confirm'));
Http::assertNothingSent();
});
test('a linked active user can sign in with microsoft', function () {
$user = User::factory()->create();
ExternalIdentity::query()->create([
'user_id' => $user->id,
'provider' => 'microsoft',
'provider_subject' => 'subject-123',
'tenant_id' => microsoftTenantId(),
'provider_email' => $user->email,
]);
$flow = microsoftFlow();
fakeMicrosoftAuthentication($flow, ['email' => $user->email]);
$response = $this->withSession([
'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null],
])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code']));
$response->assertRedirect(route('dashboard', absolute: false));
$this->assertAuthenticatedAs($user);
expect($user->fresh()->last_login_at)->not->toBeNull()
->and(AuditLog::query()->where('event', 'identity.microsoft_login')->count())->toBe(1);
});
test('a linked inactive user cannot sign in with microsoft', function () {
$user = User::factory()->create(['active' => false]);
ExternalIdentity::query()->create([
'user_id' => $user->id,
'provider' => 'microsoft',
'provider_subject' => 'subject-123',
'tenant_id' => microsoftTenantId(),
'provider_email' => $user->email,
]);
$flow = microsoftFlow();
fakeMicrosoftAuthentication($flow, ['email' => $user->email]);
$this->withSession([
'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null],
])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code']))
->assertRedirect(route('login'));
$this->assertGuest();
});
test('email coincidence never links a microsoft identity automatically', function () {
$user = User::factory()->create();
$flow = microsoftFlow();
fakeMicrosoftAuthentication($flow, ['email' => $user->email]);
$response = $this->withSession([
'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null],
])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code']));
$response->assertRedirect(route('login'));
$response->assertSessionHas('error');
$this->assertGuest();
$this->assertDatabaseCount('external_identities', 0);
});
test('callback rejects state mismatch before exchanging a code', function () {
Http::fake();
$flow = microsoftFlow();
$response = $this->withSession([
'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null],
])->get(route('auth.microsoft.callback', ['state' => 'wrong-state', 'code' => 'valid-code']));
$response->assertRedirect(route('login'));
$this->assertGuest();
Http::assertNothingSent();
});
test('callback rejects an id token from another tenant', function () {
$flow = microsoftFlow();
fakeMicrosoftAuthentication($flow, [
'tid' => '22222222-2222-4222-8222-222222222222',
'iss' => 'https://login.microsoftonline.com/22222222-2222-4222-8222-222222222222/v2.0',
]);
$response = $this->withSession([
'microsoft_oidc' => [...$flow, 'intent' => 'login', 'user_id' => null],
])->get(route('auth.microsoft.callback', ['state' => $flow['state'], 'code' => 'valid-code']));
$response->assertRedirect(route('login'));
$this->assertGuest();
});
function configureMicrosoft(): void
{
config()->set('services.microsoft', [
'enabled' => true,
'client_id' => microsoftClientId(),
'client_secret' => 'test-client-secret',
'tenant_id' => microsoftTenantId(),
'redirect' => 'http://localhost/auth/microsoft/callback',
'timeout' => 2,
'metadata_cache_seconds' => 60,
]);
}
function microsoftTenantId(): string
{
return '11111111-1111-4111-8111-111111111111';
}
function microsoftClientId(): string
{
return 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
}
/** @return array{state:string,nonce:string,code_verifier:string,created_at:int} */
function microsoftFlow(): array
{
return [
'state' => Str::random(64),
'nonce' => Str::random(64),
'code_verifier' => Str::random(96),
'created_at' => time(),
];
}
function fakeMicrosoftMetadata(): void
{
$authority = 'https://login.microsoftonline.com/'.microsoftTenantId();
Http::fake([
$authority.'/v2.0/.well-known/openid-configuration' => Http::response([
'issuer' => $authority.'/v2.0',
'authorization_endpoint' => $authority.'/oauth2/v2.0/authorize',
'token_endpoint' => $authority.'/oauth2/v2.0/token',
'jwks_uri' => $authority.'/discovery/v2.0/keys',
]),
]);
}
/** @param array<string, mixed> $overrides */
function fakeMicrosoftAuthentication(array $flow, array $overrides = []): void
{
$authority = 'https://login.microsoftonline.com/'.microsoftTenantId();
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($key === false) {
throw new RuntimeException('Could not generate a test RSA key.');
}
openssl_pkey_export($key, $privateKey);
$details = openssl_pkey_get_details($key);
if (! is_array($details) || ! is_array($details['rsa'] ?? null)) {
throw new RuntimeException('Could not read the test RSA key.');
}
$claims = array_merge([
'iss' => $authority.'/v2.0',
'aud' => microsoftClientId(),
'iat' => time() - 10,
'nbf' => time() - 10,
'exp' => time() + 300,
'nonce' => $flow['nonce'],
'ver' => '2.0',
'tid' => microsoftTenantId(),
'sub' => 'subject-123',
'oid' => 'object-123',
'name' => 'Pessoa Institucional',
'email' => 'pessoa@escola.pt',
'preferred_username' => 'pessoa@escola.pt',
], $overrides);
$idToken = JWT::encode($claims, $privateKey, 'RS256', 'test-key');
Http::fake([
$authority.'/v2.0/.well-known/openid-configuration' => Http::response([
'issuer' => $authority.'/v2.0',
'authorization_endpoint' => $authority.'/oauth2/v2.0/authorize',
'token_endpoint' => $authority.'/oauth2/v2.0/token',
'jwks_uri' => $authority.'/discovery/v2.0/keys',
]),
$authority.'/oauth2/v2.0/token' => Http::response(['id_token' => $idToken]),
$authority.'/discovery/v2.0/keys' => Http::response([
'keys' => [[
'kty' => 'RSA',
'use' => 'sig',
'kid' => 'test-key',
'alg' => 'RS256',
'n' => base64UrlEncode($details['rsa']['n']),
'e' => base64UrlEncode($details['rsa']['e']),
]],
]),
]);
}
function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}