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')); } }