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 $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), '+/', '-_'), '='); }