OidcFlowTest.php 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Auth;
  4. use App\Auth\OidcAuthenticator;
  5. use App\Auth\OidcClaims;
  6. use App\Auth\OidcException;
  7. use App\Tests\Integration\Support\AppTestCase;
  8. /**
  9. * OIDC flow tested with a stub `OidcAuthenticator`. Real Entra
  10. * authentication is verified manually; this test guards against
  11. * regressions in the controller's success/no-access/error branches.
  12. */
  13. final class OidcFlowTest extends AppTestCase
  14. {
  15. protected function setUp(): void
  16. {
  17. $this->bootApp(['oidc_enabled' => true]);
  18. }
  19. public function testCallbackSuccessSetsSessionAndRedirectsToMe(): void
  20. {
  21. $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
  22. public function authenticate(): OidcClaims
  23. {
  24. return new OidcClaims(
  25. subject: 'sub-1',
  26. email: 'alice@example.com',
  27. displayName: 'Alice',
  28. groups: ['group-admin'],
  29. );
  30. }
  31. });
  32. $this->enqueueApiResponse(200, [
  33. 'user_id' => 99, 'role' => 'admin', 'email' => 'alice@example.com',
  34. 'display_name' => 'Alice', 'is_local' => false,
  35. ]);
  36. $response = $this->request('GET', '/oidc/callback');
  37. self::assertSame(302, $response->getStatusCode());
  38. self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
  39. self::assertSame(99, $_SESSION['_user']['user_id'] ?? null);
  40. self::assertSame('admin', $_SESSION['_user']['role']);
  41. }
  42. public function testNoneRoleRedirectsToNoAccess(): void
  43. {
  44. $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
  45. public function authenticate(): OidcClaims
  46. {
  47. return new OidcClaims('sub-x', 'x@x', 'X', []);
  48. }
  49. });
  50. $this->enqueueApiResponse(200, [
  51. 'user_id' => 0, 'role' => 'none', 'email' => 'x@x', 'display_name' => 'X', 'is_local' => false,
  52. ]);
  53. $response = $this->request('GET', '/oidc/callback');
  54. self::assertSame(302, $response->getStatusCode());
  55. self::assertSame('/no-access', $response->getHeaderLine('Location'));
  56. self::assertArrayNotHasKey('_user', $_SESSION);
  57. }
  58. public function testHandshakeFailureRedirectsToLogin(): void
  59. {
  60. $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
  61. public function authenticate(): OidcClaims
  62. {
  63. throw new OidcException('state mismatch');
  64. }
  65. });
  66. $response = $this->request('GET', '/oidc/callback');
  67. self::assertSame(302, $response->getStatusCode());
  68. self::assertSame('/login', $response->getHeaderLine('Location'));
  69. $flash = $_SESSION['_flash'] ?? [];
  70. self::assertNotEmpty($flash);
  71. self::assertSame('error', $flash[0]['type']);
  72. }
  73. public function testInitiateRotatesSessionIdBeforeAuthenticate(): void
  74. {
  75. // SEC_REVIEW F9: the session id must be rotated at the top of
  76. // /login/oidc, BEFORE the authenticator stashes state/nonce/PKCE
  77. // in $_SESSION. An attacker who pre-fixated the victim's cookie
  78. // loses contact at this moment.
  79. //
  80. // Drive an initial request to establish a session id; capture it
  81. // and the id observed inside authenticate(); assert they differ.
  82. $this->request('GET', '/login');
  83. $preId = session_id();
  84. self::assertNotSame('', $preId);
  85. $capture = new \stdClass();
  86. $capture->idAtAuthenticate = null;
  87. $this->bindOidcAuthenticator(new class ($capture) implements OidcAuthenticator {
  88. public function __construct(private readonly \stdClass $capture)
  89. {
  90. }
  91. public function authenticate(): OidcClaims
  92. {
  93. $this->capture->idAtAuthenticate = session_id();
  94. // Short-circuit: we only care about the pre-handshake
  95. // rotation, not the rest of the OIDC flow.
  96. throw new OidcException('captured for test');
  97. }
  98. });
  99. $this->request('GET', '/login/oidc');
  100. self::assertIsString($capture->idAtAuthenticate);
  101. self::assertNotSame('', $capture->idAtAuthenticate);
  102. self::assertNotSame(
  103. $preId,
  104. $capture->idAtAuthenticate,
  105. 'session id was not rotated before OIDC handshake (F9 regression)',
  106. );
  107. }
  108. public function testApiUnreachableDuringUpsertFlashesAndRedirects(): void
  109. {
  110. $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
  111. public function authenticate(): OidcClaims
  112. {
  113. return new OidcClaims('sub-1', null, 'Alice', []);
  114. }
  115. });
  116. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  117. 'down',
  118. new \GuzzleHttp\Psr7\Request('POST', '/'),
  119. ));
  120. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  121. 'down',
  122. new \GuzzleHttp\Psr7\Request('POST', '/'),
  123. ));
  124. $response = $this->request('GET', '/oidc/callback');
  125. self::assertSame(302, $response->getStatusCode());
  126. self::assertSame('/login', $response->getHeaderLine('Location'));
  127. }
  128. }