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