| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Integration\Auth;
- use App\Auth\OidcAuthenticator;
- use App\Auth\OidcClaims;
- use App\Auth\OidcException;
- use App\Tests\Integration\Support\AppTestCase;
- /**
- * OIDC flow tested with a stub `OidcAuthenticator`. Real Entra
- * authentication is verified manually; this test guards against
- * regressions in the controller's success/no-access/error branches.
- */
- final class OidcFlowTest extends AppTestCase
- {
- protected function setUp(): void
- {
- $this->bootApp(['oidc_enabled' => true]);
- }
- public function testCallbackSuccessSetsSessionAndRedirectsToMe(): void
- {
- $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
- public function authenticate(): OidcClaims
- {
- return new OidcClaims(
- subject: 'sub-1',
- email: 'alice@example.com',
- displayName: 'Alice',
- groups: ['group-admin'],
- );
- }
- });
- $this->enqueueApiResponse(200, [
- 'user_id' => 99, 'role' => 'admin', 'email' => 'alice@example.com',
- 'display_name' => 'Alice', 'is_local' => false,
- ]);
- $response = $this->request('GET', '/oidc/callback');
- self::assertSame(302, $response->getStatusCode());
- self::assertSame('/app/dashboard', $response->getHeaderLine('Location'));
- self::assertSame(99, $_SESSION['_user']['user_id'] ?? null);
- self::assertSame('admin', $_SESSION['_user']['role']);
- }
- public function testNoneRoleRedirectsToNoAccess(): void
- {
- $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
- public function authenticate(): OidcClaims
- {
- return new OidcClaims('sub-x', 'x@x', 'X', []);
- }
- });
- $this->enqueueApiResponse(200, [
- 'user_id' => 0, 'role' => 'none', 'email' => 'x@x', 'display_name' => 'X', 'is_local' => false,
- ]);
- $response = $this->request('GET', '/oidc/callback');
- self::assertSame(302, $response->getStatusCode());
- self::assertSame('/no-access', $response->getHeaderLine('Location'));
- self::assertArrayNotHasKey('_user', $_SESSION);
- }
- public function testHandshakeFailureRedirectsToLogin(): void
- {
- $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
- public function authenticate(): OidcClaims
- {
- throw new OidcException('state mismatch');
- }
- });
- $response = $this->request('GET', '/oidc/callback');
- self::assertSame(302, $response->getStatusCode());
- self::assertSame('/login', $response->getHeaderLine('Location'));
- $flash = $_SESSION['_flash'] ?? [];
- self::assertNotEmpty($flash);
- self::assertSame('error', $flash[0]['type']);
- }
- public function testInitiateRotatesSessionIdBeforeAuthenticate(): void
- {
- // SEC_REVIEW F9: the session id must be rotated at the top of
- // /login/oidc, BEFORE the authenticator stashes state/nonce/PKCE
- // in $_SESSION. An attacker who pre-fixated the victim's cookie
- // loses contact at this moment.
- //
- // Drive an initial request to establish a session id; capture it
- // and the id observed inside authenticate(); assert they differ.
- $this->request('GET', '/login');
- $preId = session_id();
- self::assertNotSame('', $preId);
- $capture = new \stdClass();
- $capture->idAtAuthenticate = null;
- $this->bindOidcAuthenticator(new class ($capture) implements OidcAuthenticator {
- public function __construct(private readonly \stdClass $capture)
- {
- }
- public function authenticate(): OidcClaims
- {
- $this->capture->idAtAuthenticate = session_id();
- // Short-circuit: we only care about the pre-handshake
- // rotation, not the rest of the OIDC flow.
- throw new OidcException('captured for test');
- }
- });
- $this->request('GET', '/login/oidc');
- self::assertIsString($capture->idAtAuthenticate);
- self::assertNotSame('', $capture->idAtAuthenticate);
- self::assertNotSame(
- $preId,
- $capture->idAtAuthenticate,
- 'session id was not rotated before OIDC handshake (F9 regression)',
- );
- }
- public function testNoneRoleDoesNotLogRawSubject(): void
- {
- // SEC_REVIEW F34: a SIEM operator must not see the raw OIDC subject
- // in the no-role-assigned warning. A stable fingerprint is logged
- // instead so triage can still correlate repeated denials for the
- // same user.
- $rawSubject = 'aad-objectid-secret-9b6f7e3c-1234-5678';
- $this->bindOidcAuthenticator(new class ($rawSubject) implements OidcAuthenticator {
- public function __construct(private readonly string $sub)
- {
- }
- public function authenticate(): OidcClaims
- {
- return new OidcClaims($this->sub, 'x@x', 'X', []);
- }
- });
- $this->enqueueApiResponse(200, [
- 'user_id' => 0, 'role' => 'none', 'email' => 'x@x', 'display_name' => 'X', 'is_local' => false,
- ]);
- $logs = $this->captureLogs();
- $this->request('GET', '/oidc/callback');
- self::assertNotEmpty($logs->getRecords(), 'expected the no-role denial to be logged');
- foreach ($logs->getRecords() as $record) {
- $serialized = json_encode([
- 'message' => $record->message,
- 'context' => $record->context,
- ], \JSON_THROW_ON_ERROR);
- self::assertStringNotContainsString($rawSubject, $serialized);
- self::assertArrayNotHasKey('subject', $record->context);
- }
- self::assertTrue($logs->hasWarningThatContains('oidc user has no role assigned'));
- }
- public function testDisabledUserDeniedDoesNotLogRawSubject(): void
- {
- // SEC_REVIEW F34: same redaction on the user_disabled branch.
- $rawSubject = 'aad-objectid-other-7c8d2f';
- $this->bindOidcAuthenticator(new class ($rawSubject) implements OidcAuthenticator {
- public function __construct(private readonly string $sub)
- {
- }
- public function authenticate(): OidcClaims
- {
- return new OidcClaims($this->sub, 'x@x', 'X', []);
- }
- });
- $this->enqueueApiResponse(403, ['error' => 'user_disabled']);
- $logs = $this->captureLogs();
- $this->request('GET', '/oidc/callback');
- foreach ($logs->getRecords() as $record) {
- $serialized = json_encode([
- 'message' => $record->message,
- 'context' => $record->context,
- ], \JSON_THROW_ON_ERROR);
- self::assertStringNotContainsString($rawSubject, $serialized);
- self::assertArrayNotHasKey('subject', $record->context);
- }
- self::assertTrue($logs->hasWarningThatContains('oidc login denied: user disabled'));
- }
- public function testApiUnreachableDuringUpsertFlashesAndRedirects(): void
- {
- $this->bindOidcAuthenticator(new class () implements OidcAuthenticator {
- public function authenticate(): OidcClaims
- {
- return new OidcClaims('sub-1', null, 'Alice', []);
- }
- });
- $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
- 'down',
- new \GuzzleHttp\Psr7\Request('POST', '/'),
- ));
- $this->enqueueApiException(new \GuzzleHttp\Exception\ConnectException(
- 'down',
- new \GuzzleHttp\Psr7\Request('POST', '/'),
- ));
- $response = $this->request('GET', '/oidc/callback');
- self::assertSame(302, $response->getStatusCode());
- self::assertSame('/login', $response->getHeaderLine('Location'));
- }
- }
|