key = openssl_pkey_new([ 'private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); if ($this->key === false) { self::fail('konnte RSA-Schlüssel generieren.'); } $details = openssl_pkey_get_details($this->key); $n = $details['n'] ?? $details['rsa']['n']; $e = $details['e'] ?? $details['rsa']['e']; $this->jwk = [ 'n' => self::b64url($n), 'e' => self::b64url($e), ]; } public function testVerifyIdTokenAkzeptiertGueltigSigniertesToken(): void { $jwt = $this->signJwt(['sub' => 'jens@ljh31.de', 'email' => 'jens@ljh31.de']); $claims = $this->oidcClient()->verifyIdToken($jwt); self::assertSame('jens@ljh31.de', $claims['sub']); self::assertSame('jens@ljh31.de', $claims['email']); self::assertSame(self::ISSUER, $claims['iss']); } public function testVerifyIdTokenAkzeptiertAudienceAlsArray(): void { $jwt = $this->signJwt(['sub' => 'x'], ['aud' => ['essen', 'anderer-client']]); $claims = $this->oidcClient()->verifyIdToken($jwt); self::assertSame('x', $claims['sub']); } public function testVerifyIdTokenVerwirftManipuliertesPayload(): void { $jwt = $this->signJwt(['sub' => 'jens@ljh31.de']); $tampered = $this->tamper($jwt, 'jens@ljh31.de', 'admin@ljh31.de'); $this->expectException(\RuntimeException::class); $this->oidcClient()->verifyIdToken($tampered); } public function testVerifyIdTokenVerwirftFalscheIssuer(): void { $jwt = $this->signJwt(['sub' => 'x'], ['iss' => 'https://anderer.issuer/realms/family']); $this->expectException(\RuntimeException::class); $this->oidcClient()->verifyIdToken($jwt); } public function testVerifyIdTokenVerwirftFalscheAudience(): void { $jwt = $this->signJwt(['sub' => 'x'], ['aud' => 'falscher-client']); $this->expectException(\RuntimeException::class); $this->oidcClient()->verifyIdToken($jwt); } public function testVerifyIdTokenVerwirftAbgelaufenesToken(): void { $jwt = $this->signJwt(['sub' => 'x'], ['exp' => time() - 3600]); $this->expectException(\RuntimeException::class); $this->oidcClient()->verifyIdToken($jwt); } public function testVerifyIdTokenWähltRichtigenKeyBeiMehrfachJwks(): void { // Zusätzliches RSA-OAEP-Enc-Key (wie in der echten Keycloak-Realm): // muss ignoriert werden, obwohl es denselben Modulus teilt. $jwt = $this->signJwt(['sub' => 'x']); $claims = $this->oidcClient(extraKey: [ 'kty' => 'RSA', 'alg' => 'RSA-OAEP', 'use' => 'enc', 'kid' => 'test-kid-enc', 'n' => $this->jwk['n'], 'e' => $this->jwk['e'], ])->verifyIdToken($jwt); self::assertSame('x', $claims['sub']); } private function signJwt(array $claims, array $overrides = []): string { $header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => self::KID]; $payload = array_merge([ 'iss' => self::ISSUER, 'aud' => self::CLIENT_ID, 'exp' => time() + 300, 'iat' => time(), ], $overrides, $claims); $h64 = self::b64url((string) json_encode($header)); $p64 = self::b64url((string) json_encode($payload)); if (openssl_sign("$h64.$p64", $sig, $this->key, OPENSSL_ALGO_SHA256) !== true) { self::fail('konnte Test-JWT signieren.'); } return "$h64.$p64." . self::b64url($sig); } private function tamper(string $jwt, string $from, string $to): string { $parts = explode('.', $jwt); $payload = json_decode((string) base64_decode(strtr($parts[1], '-_', '+/')), true); $payload['sub'] = $to; $parts[1] = self::b64url((string) json_encode($payload)); return implode('.', $parts); } private function oidcClient(?array $extraKey = null): OidcClient { $keys = [[ 'kty' => 'RSA', 'alg' => 'RS256', 'use' => 'sig', 'kid' => self::KID, 'n' => $this->jwk['n'], 'e' => $this->jwk['e'], ]]; if ($extraKey !== null) { array_unshift($keys, $extraKey); } $httpClient = new MockHttpClient(new MockResponse((string) json_encode(['keys' => $keys]))); return new OidcClient( httpClient: $httpClient, issuer: self::ISSUER, clientId: self::CLIENT_ID, clientSecret: null, redirectUri: 'https://app.test/auth/callback', ); } private static function b64url(string $binary): string { return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); } }