diff --git a/Dockerfile b/Dockerfile
index 95f39ce..8763a21 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,20 +1,21 @@
-FROM php:8.4-frankenphp
+FROM dunglas/frankenphp:1.12.7-php8.4
ENV APP_ENV=prod \
- COMPOSER_ALLOW_SUPERUSER=1
+ COMPOSER_ALLOW_SUPERUSER=1 \
+ SERVER_NAME=:8787
RUN apt-get update \
- && apt-get install -y --no-install-recommends git unzip sqlite3 \
+ && apt-get install -y --no-install-recommends git unzip sqlite3 libicu-dev \
&& rm -rf /var/lib/apt/lists/*
-RUN docker-php-ext-install pdo pdo_sqlite opcache bcmath intl
+RUN docker-php-ext-install bcmath intl
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
# composer.json zuerst → Layer-Cache
COPY composer.json composer.lock ./
-RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist
+RUN composer install --no-dev --optimize-autoloader --no-interaction --prefer-dist --no-scripts
COPY . .
diff --git a/Doku/plan.md b/Doku/plan.md
index faea34a..0345f31 100644
Binary files a/Doku/plan.md and b/Doku/plan.md differ
diff --git a/compose.yaml b/compose.yaml
index 5515c62..4d94ad5 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -4,16 +4,20 @@ services:
container_name: svc-essensplanung
restart: unless-stopped
env_file:
- - .env
+ # Secrets + Prod-Redirect-URI: APP_SECRET, API_TOKEN, KEYCLOAK_REDIRECT_URI
+ - .env.local
environment:
APP_ENV: prod
APP_DEBUG: '0'
DATABASE_URL: sqlite:////data/essens.db
+ DEFAULT_URI: https://essen.ljh31.de
+ KEYCLOAK_ISSUER: https://iam.ljh31.de/realms/family
+ KEYCLOAK_CLIENT_ID: essen
volumes:
- essens_data:/data
- essens_var:/app/var
healthcheck:
- test: ['CMD', 'php', '-r', '$c=@fsockopen("127.0.0.1",8787); exit($c?0:1);']
+ test: ['CMD', 'php', '-r', '$$c=@fsockopen("127.0.0.1",8787); exit($$c?0:1);']
interval: 30s
timeout: 5s
retries: 3
diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png
new file mode 100644
index 0000000..3c017f3
Binary files /dev/null and b/public/icons/apple-touch-icon.png differ
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..1220f78
Binary files /dev/null and b/public/icons/icon-192.png differ
diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png
new file mode 100644
index 0000000..760ec76
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/icons/maskable-512.png b/public/icons/maskable-512.png
new file mode 100644
index 0000000..8966487
Binary files /dev/null and b/public/icons/maskable-512.png differ
diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest
new file mode 100644
index 0000000..6a7dd08
--- /dev/null
+++ b/public/manifest.webmanifest
@@ -0,0 +1,17 @@
+{
+ "name": "Essensplanung",
+ "short_name": "Essen",
+ "description": "Familien-Essensplanung: Planungsliste, Tagesansicht, Inspiration.",
+ "lang": "de",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "portrait",
+ "background_color": "#f4eee1",
+ "theme_color": "#3f6d4e",
+ "icons": [
+ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
+ { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
+ { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
+ ]
+}
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..fde559b
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,79 @@
+/* Service Worker: App-Shell cachen (Offline + schneller Start).
+ Version bei jeder Template-/Asset-Änderung bumpen (SHELL_VERSION),
+ damit alte Caches beim Activate bereinigt werden. */
+const SHELL_VERSION = 'v1';
+const SHELL_CACHE = 'essen-shell-' + SHELL_VERSION;
+const SHELL = [
+ '/',
+ '/manifest.webmanifest',
+ '/js/htmx.min.js',
+ '/js/sortable.min.js',
+ '/icons/icon-192.png',
+ '/icons/icon-512.png',
+];
+
+self.addEventListener('install', (event) => {
+ event.waitUntil(
+ caches
+ .open(SHELL_CACHE)
+ .then((cache) => cache.addAll(SHELL))
+ .then(() => self.skipWaiting())
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(
+ keys
+ .filter((key) => key.startsWith('essen-shell-') && key !== SHELL_CACHE)
+ .map((key) => caches.delete(key))
+ )
+ )
+ .then(() => self.clients.claim())
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const url = new URL(event.request.url);
+ if (event.request.method !== 'GET' || url.origin !== self.location.origin) {
+ return;
+ }
+
+ // Navigations: erst Netzwerk (frische Deploys!), dann Cache (Offline).
+ if (event.request.mode === 'navigate') {
+ event.respondWith(
+ fetch(event.request)
+ .then((response) => {
+ const kopie = response.clone();
+ caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie));
+ return response;
+ })
+ .catch(() =>
+ caches.match(event.request).then((treffer) => treffer || caches.match('/'))
+ )
+ );
+ return;
+ }
+
+ // Statische Assets: Cache-first (unveränderlich pro Version).
+ if (
+ url.pathname.startsWith('/js/') ||
+ url.pathname.startsWith('/icons/') ||
+ url.pathname === '/manifest.webmanifest'
+ ) {
+ event.respondWith(
+ caches.match(event.request).then(
+ (treffer) =>
+ treffer ||
+ fetch(event.request).then((response) => {
+ const kopie = response.clone();
+ caches.open(SHELL_CACHE).then((cache) => cache.put(event.request, kopie));
+ return response;
+ })
+ )
+ );
+ }
+});
diff --git a/src/UI/Http/Auth/OidcAuthController.php b/src/UI/Http/Auth/OidcAuthController.php
index 2ac7fd3..6c413c6 100644
--- a/src/UI/Http/Auth/OidcAuthController.php
+++ b/src/UI/Http/Auth/OidcAuthController.php
@@ -65,7 +65,8 @@ final class OidcAuthController
try {
$tokens = $this->oidc->exchangeCode($code);
$claims = $this->oidc->verifyIdToken($tokens['id_token']);
- } catch (\Throwable) {
+ } catch (\Throwable $e) {
+ error_log('OIDC-Callback fehlgeschlagen: ' . $e->getMessage());
return new RedirectResponse($this->router->generate('login'));
}
diff --git a/src/UI/Http/Auth/OidcClient.php b/src/UI/Http/Auth/OidcClient.php
index 7f125c7..0a6c87f 100644
--- a/src/UI/Http/Auth/OidcClient.php
+++ b/src/UI/Http/Auth/OidcClient.php
@@ -41,7 +41,6 @@ final class OidcClient
'client_id' => $this->clientId,
'redirect_uri' => $this->redirectUri,
'scope' => implode(' ', $this->scopes),
- 'prompt' => 'login',
];
if ($state !== null && $state !== '') {
$q['state'] = $state;
@@ -71,7 +70,8 @@ final class OidcClient
}
$resp = $this->httpClient->request('POST', $this->issuer . '/protocol/openid-connect/token', [
- 'json' => $body,
+ 'body' => http_build_query($body),
+ 'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
'timeout' => 10,
]);
$sc = $resp->getStatusCode();
@@ -198,10 +198,9 @@ final class OidcClient
$mod = $this->derInteger($n);
$exp = $this->derInteger($e);
- $rsakey =
- "\x30" . $this->derLen(strlen($mod) + strlen($exp))
- . "\x02" . $this->derLen(strlen($mod)) . $mod
- . "\x02" . $this->derLen(strlen($exp)) . $exp;
+ $modTLV = "\x02" . $this->derLen(strlen($mod)) . $mod;
+ $expTLV = "\x02" . $this->derLen(strlen($exp)) . $exp;
+ $rsakey = "\x30" . $this->derLen(strlen($modTLV) + strlen($expTLV)) . $modTLV . $expTLV;
// rsaEncryption OID 1.2.840.113549.1.1.1 → DER: 2A 86 48 86 F7 0D 01 01 01
$algId = "\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01";
diff --git a/src/UI/Http/Essen/MahlzeitController.php b/src/UI/Http/Essen/MahlzeitController.php
index 864e49e..dbee106 100644
--- a/src/UI/Http/Essen/MahlzeitController.php
+++ b/src/UI/Http/Essen/MahlzeitController.php
@@ -78,9 +78,18 @@ final class MahlzeitController extends AbstractController
return $this->render('mahlzeiten/tage.html.twig', [
'tage' => $this->gruppierenNachTag($mahlzeiten),
'total' => \count($mahlzeiten),
+ 'heute' => $this->heutigerTag(),
]);
}
+ /** Aktueller Wochentag (Europe/Berlin) als `Wochentag`-Name (z. B. "Di"). */
+ private function heutigerTag(): string
+ {
+ $iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
+
+ return Wochentag::cases()[$iso - 1]->name;
+ }
+
/**
* POST /mahlzeiten/reorder — neue Reihenfolge der Planungsliste (Drag & Drop)
* persistieren. Erwartet JSON `{"ids": [3, 1, 2]}` oder das Formularfeld
diff --git a/src/UI/Http/ExceptionSubscriber.php b/src/UI/Http/ExceptionSubscriber.php
index d4adef1..3aa1b27 100644
--- a/src/UI/Http/ExceptionSubscriber.php
+++ b/src/UI/Http/ExceptionSubscriber.php
@@ -47,7 +47,7 @@ final class ExceptionSubscriber implements EventSubscriberInterface
return;
}
- if ($e instanceof HttpExceptionInterface && $api) {
+ if ($e instanceof HttpExceptionInterface) {
$event->setResponse(
$this->render($request, $e->getStatusCode(), $this->shortName(get_class($e)), $e->getMessage(), $wantsJson)
);
diff --git a/templates/home/index.html.twig b/templates/home/index.html.twig
index f88958b..ef792c1 100644
--- a/templates/home/index.html.twig
+++ b/templates/home/index.html.twig
@@ -3,11 +3,15 @@
{% block title %}Start · svc-essensplanung{% endblock %}
{% block body %}
-
{{ name }}
+ Willkommen, {{ name }}!
+ Deine Woche auf einen Blick – was steht an und was schmeckt mal wieder?
+
-
Willkommen zur Essensplanung! (M0-Skelett)
-
E-Mail: {{ email }}
-
Redirect-Pfad: {{ redirect }}
-
M1: CRUD-Slice (Dish-Create/Update/Delete/List) folgt.
+
+
Angemeldet als {{ email }}
{% endblock %}
diff --git a/templates/layout/base.html.twig b/templates/layout/base.html.twig
index 51c60f6..53423a1 100644
--- a/templates/layout/base.html.twig
+++ b/templates/layout/base.html.twig
@@ -2,102 +2,423 @@
-
+
{% block title %}svc-essensplanung{% endblock %}
+
+
+
+
+
+
+
{% block stylesheets %}{% endblock %}
+ {% block fab %}
+
+
+
+ {% endblock %}
{% for label, messages in app.flashes %}
{% for message in messages %}
@@ -108,5 +429,12 @@
{% block javascripts %}{% endblock %}
+
diff --git a/templates/mahlzeiten/_mahlzeit.html.twig b/templates/mahlzeiten/_mahlzeit.html.twig
index ab4ea54..e9a9cd2 100644
--- a/templates/mahlzeiten/_mahlzeit.html.twig
+++ b/templates/mahlzeiten/_mahlzeit.html.twig
@@ -3,49 +3,98 @@
eingebunden und als htmx-Fragment von den CheckOff/Uncheck-Endpunkten
zurückgeliefert (htmx ersetzt per hx-swap="outerHTML" genau diese ).
- @param MahlzeitResponse m
- @param bool sortierbar optional: zeigt Drag-Handle + data-id (Planungsliste)
-#}
-
-
+
{% if sortierbar is defined and sortierbar %}
-
⋮
+
+
+
+
+
+
+
{% endif %}
-
{{ m.name }}
- {% if m.notiz != '' %}
-
{{ m.notiz }}
- {% endif %}
- {% if m.wochentage|length > 0 %}
-
- {% for tag in m.wochentage %}
- {{ tag.name }}
- {% endfor %}
-
- {% endif %}
-
- {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
- {% if m.status.value == 'gegessen' and m.gegessenAm %}
- · {{ m.gegessenAm|date('d.m.Y H:i') }}
+
+
{{ m.name }}
+ {% if m.notiz != '' %}
+
{{ m.notiz }}
{% endif %}
-
+ {% if m.wochentage|length > 0 %}
+
+ {% for tag in m.wochentage %}
+ {{ tag.name }}
+ {% endfor %}
+
+ {% endif %}
+
+ {{ m.status.value == 'gegessen' ? 'gegessen' : 'geplant' }}
+ {% if m.status.value == 'gegessen' and m.gegessenAm %}
+ · {{ m.gegessenAm|date('d.m.Y H:i') }}
+ {% endif %}
+
+
- {% if m.status.value == 'geplant' %}
-
- {% else %}
-
- {% endif %}
-
Bearbeiten
-
+
+ {% if m.status.value == 'geplant' %}
+
+ {% else %}
+
+ {% endif %}
+
+
diff --git a/templates/mahlzeiten/archiv.html.twig b/templates/mahlzeiten/archiv.html.twig
index f650cce..c776aaa 100644
--- a/templates/mahlzeiten/archiv.html.twig
+++ b/templates/mahlzeiten/archiv.html.twig
@@ -4,18 +4,19 @@
{% block body %}
-
-
Archiv
+
+
+
Archiv
+
Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.
+
-
Alle abgehakten (gegessenen) Mahlzeiten. „Aufheben" stellt sie wieder auf „geplant" um.
-
{% if mahlzeiten|length == 0 %}
-
Noch nichts gegessen – das Archiv ist leer.
+
Noch nichts gegessen – das Archiv ist leer.
{% else %}
{% for m in mahlzeiten %}
diff --git a/templates/mahlzeiten/form.html.twig b/templates/mahlzeiten/form.html.twig
index 3518721..99358ef 100644
--- a/templates/mahlzeiten/form.html.twig
+++ b/templates/mahlzeiten/form.html.twig
@@ -4,9 +4,16 @@
{% block title %}{{ titel }} · svc-essensplanung{% endblock %}
+{% block fab %}{% endblock %}
+
{% block body %}
-
{{ titel }}
+
+
+
{{ titel }}
+
{{ mahlzeit ? 'Ändere Name, Notiz oder Wochentage.' : 'Ein Gericht, das wieder auf den Plan soll.' }}
+
+
{% if fehler %}
{{ fehler }}
@@ -17,12 +24,14 @@
-
+
-
+
@@ -39,7 +48,7 @@
-
+
Abbrechen
diff --git a/templates/mahlzeiten/index.html.twig b/templates/mahlzeiten/index.html.twig
index 8e74bfd..197efab 100644
--- a/templates/mahlzeiten/index.html.twig
+++ b/templates/mahlzeiten/index.html.twig
@@ -4,21 +4,23 @@
{% block body %}
-
+
-
Mahlzeiten
-
Planungsliste – per Drag & Drop sortieren
+
Mahlzeiten
+
Planungsliste – per Drag & Drop sortieren
{% if total == 0 %}
-
Noch keine Mahlzeiten in der Planung.
+
Noch keine Mahlzeiten in der Planung.
{% else %}
{% for m in mahlzeiten %}
@@ -34,26 +36,46 @@
{% endblock %}
diff --git a/templates/mahlzeiten/inspiration.html.twig b/templates/mahlzeiten/inspiration.html.twig
index b36afa4..0c54cc9 100644
--- a/templates/mahlzeiten/inspiration.html.twig
+++ b/templates/mahlzeiten/inspiration.html.twig
@@ -4,10 +4,10 @@
{% block body %}
-
+
-
Inspiration
-
Gerichte aus dem Archiv – was kochst du mal wieder?
+
Inspiration
+
Gerichte aus dem Archiv – was kochst du mal wieder?
← Mahlzeiten
@@ -18,40 +18,45 @@
{% set alleAusschluss = ausschluss + gezeigte %}
{% if total == 0 %}
-
+
Noch keine Inspiration: Hake Mahlzeiten ab – dann erscheinen sie hier als Vorschlag.
{% else %}
{% for v in vorschlaege %}
-
-
+
{{ v.name }}
{% if v.notiz != '' %}
-
{{ v.notiz }}
+
{{ v.notiz }}
{% endif %}
-
+
{% if v.gegessenAm %}
zuletzt gegessen · {{ v.gegessenAm|date('d.m.Y') }}
{% else %}
aus dem Archiv
{% endif %}
- · {{ v.auftraete }}× in der Küche
-
+ · {{ v.auftraete }}× in der Küche
+
{% endfor %}
{% if mehrVerfuegbar %}
-
+
Weitere 5 Vorschläge
diff --git a/templates/mahlzeiten/tage.html.twig b/templates/mahlzeiten/tage.html.twig
index 6722cb3..a231a65 100644
--- a/templates/mahlzeiten/tage.html.twig
+++ b/templates/mahlzeiten/tage.html.twig
@@ -4,10 +4,10 @@
{% block body %}
-
+
-
Tagesansicht
-
Alle Mahlzeiten nach Wochentag
+
Tagesansicht
+
Alle Mahlzeiten nach Wochentag
{% if total == 0 %}
-
Noch keine Mahlzeiten angelegt.
+
Noch keine Mahlzeiten angelegt.
{% else %}
{% for tag, mahlzeiten in tage %}
-
{{ tag }}
+
{{ tag }}
{% for m in mahlzeiten %}
{{ include('mahlzeiten/_mahlzeit.html.twig', { m: m }) }}
diff --git a/tests/Functional/UI/Essen/PwaSmokeTest.php b/tests/Functional/UI/Essen/PwaSmokeTest.php
new file mode 100644
index 0000000..d5d0d06
--- /dev/null
+++ b/tests/Functional/UI/Essen/PwaSmokeTest.php
@@ -0,0 +1,75 @@
+followRedirects(false);
+ $em = self::getContainer()->get('doctrine')->getManager();
+ $schemaTool = new SchemaTool($em);
+ $schemaTool->dropSchema($em->getMetadataFactory()->getAllMetadata());
+ $schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata());
+ $session = self::getContainer()->get('session.factory')->createSession();
+ $session->start();
+ foreach (['auth_sub' => 'test-sub', 'auth_name' => 'Jens', 'auth_email' => 'jens@ljh31.de'] as $k => $v) {
+ $session->set($k, $v);
+ }
+ $session->save();
+ $client->getCookieJar()->set(new Cookie($session->getName(), $session->getId()));
+
+ return $client;
+ }
+
+ public function testIndexHasPwaAndMobileMarkers(): void
+ {
+ $client = self::authedClient();
+ $client->request('GET', '/mahlzeiten');
+
+ self::assertResponseIsSuccessful();
+ $html = (string) $client->getResponse()->getContent();
+ self::assertStringContainsString('manifest.webmanifest', $html);
+ self::assertStringContainsString('class="fab"', $html);
+ self::assertStringContainsString("register('/sw.js')", $html);
+ self::assertStringContainsString('href="/mahlzeiten/tage"', $html);
+ }
+
+ public function testTageHighlightsHeutigerTag(): void
+ {
+ $client = self::authedClient();
+ $client->request('POST', '/mahlzeiten', [
+ 'name' => 'Heutiges Gericht',
+ 'notiz' => '',
+ 'wochentage' => [self::heuteWert()],
+ ]);
+ $client->request('GET', '/mahlzeiten/tage');
+
+ self::assertResponseIsSuccessful();
+ $h2 = $client->getCrawler()->filterXPath('//h2[@data-heute="1"]');
+ self::assertCount(1, $h2, 'Genau ein trägt das data-heute-Attribut.');
+ self::assertSame(self::heuteName(), trim($h2->text()));
+ }
+
+ private static function heuteName(): string
+ {
+ $iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
+
+ return ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'][$iso - 1];
+ }
+
+ private static function heuteWert(): string
+ {
+ $iso = (int) (new \DateTimeImmutable('now', new \DateTimeZone('Europe/Berlin')))->format('N');
+
+ return ['mo', 'di', 'mi', 'do', 'fr', 'sa', 'so'][$iso - 1];
+ }
+}
diff --git a/tests/Unit/UI/Http/Auth/OidcClientTest.php b/tests/Unit/UI/Http/Auth/OidcClientTest.php
new file mode 100644
index 0000000..e8f75aa
--- /dev/null
+++ b/tests/Unit/UI/Http/Auth/OidcClientTest.php
@@ -0,0 +1,174 @@
+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), '+/', '-_'), '=');
+ }
+}