M4: PWA + Mobile-UX – Manifest/Service-Worker + Icons, Auf/Ab-Reorder, FAB, Touch-Targets + Safe-Area, Nav-Entdupidung, Heute-Highlight, Aktions-Trennung (Abhaken vs. Bearbeiten/Löschen), OIDC-Fixes + PWA-Tests
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\UI\Http\Auth;
|
||||
|
||||
use App\UI\Http\Auth\OidcClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* Regressions-Test für die RS256-IdToken-Verifizierung:
|
||||
* Signiert ein JWT gegen einen lokal generierten RSA-Schlüssel und prüft
|
||||
* OidcClient::verifyIdToken() inkl. JWKS→PEM-Konvertierung.
|
||||
*/
|
||||
final class OidcClientTest extends TestCase
|
||||
{
|
||||
private const ISSUER = 'https://issuer.test/realms/family';
|
||||
private const CLIENT_ID = 'essen';
|
||||
private const KID = 'test-kid-signing';
|
||||
|
||||
/** @var resource */
|
||||
private $key;
|
||||
/** @var array{n: string, e: string} */
|
||||
private array $jwk;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user