OidcFlowTest.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  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 testNoneRoleDoesNotLogRawSubject(): void
  109. {
  110. // SEC_REVIEW F34: a SIEM operator must not see the raw OIDC subject
  111. // in the no-role-assigned warning. A stable fingerprint is logged
  112. // instead so triage can still correlate repeated denials for the
  113. // same user.
  114. $rawSubject = 'aad-objectid-secret-9b6f7e3c-1234-5678';
  115. $this->bindOidcAuthenticator(new class ($rawSubject) implements OidcAuthenticator {
  116. public function __construct(private readonly string $sub)
  117. {
  118. }
  119. public function authenticate(): OidcClaims
  120. {
  121. return new OidcClaims($this->sub, 'x@x', 'X', []);
  122. }
  123. });
  124. $this->enqueueApiResponse(200, [
  125. 'user_id' => 0, 'role' => 'none', 'email' => 'x@x', 'display_name' => 'X', 'is_local' => false,
  126. ]);
  127. $logs = $this->captureLogs();
  128. $this->request('GET', '/oidc/callback');
  129. self::assertNotEmpty($logs->getRecords(), 'expected the no-role denial to be logged');
  130. foreach ($logs->getRecords() as $record) {
  131. $serialized = json_encode([
  132. 'message' => $record->message,
  133. 'context' => $record->context,
  134. ], \JSON_THROW_ON_ERROR);
  135. self::assertStringNotContainsString($rawSubject, $serialized);
  136. self::assertArrayNotHasKey('subject', $record->context);
  137. }
  138. self::assertTrue($logs->hasWarningThatContains('oidc user has no role assigned'));
  139. }
  140. public function testDisabledUserDeniedDoesNotLogRawSubject(): void
  141. {
  142. // SEC_REVIEW F34: same redaction on the user_disabled branch.
  143. $rawSubject = 'aad-objectid-other-7c8d2f';
  144. $this->bindOidcAuthenticator(new class ($rawSubject) implements OidcAuthenticator {
  145. public function __construct(private readonly string $sub)
  146. {
  147. }
  148. public function authenticate(): OidcClaims
  149. {
  150. return new OidcClaims($this->sub, 'x@x', 'X', []);
  151. }
  152. });
  153. $this->enqueueApiResponse(403, ['error' => 'user_disabled']);
  154. $logs = $this->captureLogs();
  155. $this->request('GET', '/oidc/callback');
  156. foreach ($logs->getRecords() as $record) {
  157. $serialized = json_encode([
  158. 'message' => $record->message,
  159. 'context' => $record->context,
  160. ], \JSON_THROW_ON_ERROR);
  161. self::assertStringNotContainsString($rawSubject, $serialized);
  162. self::assertArrayNotHasKey('subject', $record->context);
  163. }
  164. self::assertTrue($logs->hasWarningThatContains('oidc login denied: user disabled'));
  165. }
  166. public function testApiUnreachableDuringUpsertFlashesAndRedirects(): void
  167. {
  168. $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
  169. public function authenticate(): OidcClaims
  170. {
  171. return new OidcClaims('sub-1', null, 'Alice', []);
  172. }
  173. });
  174. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  175. 'down',
  176. new \GuzzleHttp\Psr7\Request('POST', '/'),
  177. ));
  178. $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
  179. 'down',
  180. new \GuzzleHttp\Psr7\Request('POST', '/'),
  181. ));
  182. $response = $this->request('GET', '/oidc/callback');
  183. self::assertSame(302, $response->getStatusCode());
  184. self::assertSame('/login', $response->getHeaderLine('Location'));
  185. }
  186. }