open($zipPath, ZipArchive::CREATE);
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS)) as $f) {
$zip->addFile($f->getPathname(), str_replace('\\', '/', substr($f->getPathname(), strlen($src) + 1)));
}
$zip->close();
$r = plugin_install_zip($pdo, $zipPath);
$check($r['ok'] && $r['slug'] === 'authentik', 'Plugin lässt sich installieren: ' . json_encode($r));
$act = plugin_set_active($pdo, 'authentik', true, ['ui.menu', 'auth.login', 'auth.customer']);
$check($act['ok'], 'Plugin lässt sich mit den Berechtigungen aktivieren: ' . json_encode($act));
$state = &plugin_state();
$state['booted'] = false;
plugins_boot();
$check(isset(plugin_state()['loaded']['authentik']), 'Plugin wird geladen');
$check(isset(plugin_state()['logins']['authentik']['start'], plugin_state()['logins']['authentik']['callback']), 'Start und Rückkehr sind als Anmelde-Adressen registriert');
$menus = json_encode(plugin_state()['menus'] ?? []);
$check(str_contains($menus, 'authentik') && str_contains($menus, 'einstellungen'), 'Menüpunkt für Administratoren ist registriert');
// Mitarbeiter
$ins = $pdo->prepare("INSERT INTO staff (name, email, password_hash, role, is_active, created_at) VALUES (?,?,?,?,?,?)");
$ins->execute(['Anna', 'Anna@Firma.Example', 'x', 'agent', 1, db_now()]);
$anna = (int) $pdo->lastInsertId();
$ins->execute(['Karl', 'karl@firma.example', 'x', 'agent', 0, db_now()]);
// Ohne Einrichtung: kein Knopf, Anmeldung nicht möglich
$check(!authentik_ready() && plugin_filter('staff.login.buttons', '', []) === '', 'Ohne Einrichtung gibt es keinen Knopf');
$check(isset(authentik_login_start([])['error']), 'Ohne Einrichtung lässt sich die Anmeldung nicht starten');
// Einrichten
$issuer = 'https://auth.firma.example/application/o/deskhub';
plugin_set_setting('authentik', 'issuer', $issuer . '/');
plugin_set_setting('authentik', 'client_id', 'deskhub-client');
plugin_set_setting('authentik', 'client_secret_enc', encrypt_secret('streng-geheim'));
plugin_set_setting('authentik', 'enabled', '1');
$cfg = authentik_config();
$check($cfg['issuer'] === $issuer && $cfg['client_secret'] === 'streng-geheim' && authentik_ready(), 'Einstellungen werden gelesen (Geheimnis entschlüsselt, Schrägstrich am Ende entfernt)');
$check(!str_contains((string) get_setting('plugin.authentik.client_secret_enc', ''), 'streng-geheim'), 'Das Geheimnis liegt nicht im Klartext in der Datenbank');
$btn = plugin_filter('staff.login.buttons', '', []);
$check(str_contains($btn, 'Mit authentik anmelden') && str_contains($btn, '/staff/plugin_login.php?p=authentik&e=start'), 'Eingerichtet erscheint der Knopf mit dem richtigen Ziel');
plugin_set_setting('authentik', 'label', 'SSO Login');
$check(str_contains(plugin_filter('staff.login.buttons', '', []), 'SSO <b>Login</b>'), 'Die Beschriftung wird maskiert');
plugin_set_setting('authentik', 'label', '');
// ── authentik als Funktion nachgebaut ──
$idp = ['claims' => [], 'status' => 200, 'tokenStatus' => 200, 'userinfo' => null, 'challenge' => '', 'nonce' => '', 'calls' => []];
$jwt = fn(array $c) => 'eyJhbGciOiJub25lIn0.' . authentik_b64url(json_encode($c)) . '.sig';
$GLOBALS['authentik_http'] = function (string $method, string $url, array $opts) use (&$idp, $issuer, $jwt): array {
$idp['calls'][] = "$method $url";
if ($url === $issuer . '/.well-known/openid-configuration') {
return [$idp['status'], json_encode(['issuer' => $idp['issuerOverride'] ?? $issuer . '/', 'authorization_endpoint' => 'https://auth.firma.example/application/o/authorize/', 'token_endpoint' => 'https://auth.firma.example/application/o/token/', 'userinfo_endpoint' => 'https://auth.firma.example/application/o/userinfo/']), null];
}
if ($url === 'https://auth.firma.example/application/o/token/') {
$f = $opts['form'] ?? [];
// PKCE: der Prüfwert muss zur Herausforderung aus dem Start passen
$pkceOk = authentik_b64url(hash('sha256', (string) ($f['code_verifier'] ?? ''), true)) === $idp['challenge'];
if ($f['client_secret'] !== 'streng-geheim' || $f['client_id'] !== 'deskhub-client' || $f['grant_type'] !== 'authorization_code' || $f['code'] !== 'gueltiger-code' || !$pkceOk || !str_contains($f['redirect_uri'], '/staff/plugin_login.php?p=authentik&e=callback')) return [400, json_encode(['error' => 'invalid_grant']), null];
$claims = $idp['claims'] + ['iss' => $issuer . '/', 'aud' => 'deskhub-client', 'exp' => time() + 300, 'nonce' => $idp['nonce'], 'sub' => 'u1', 'email' => 'anna@firma.example', 'email_verified' => true];
return [$idp['tokenStatus'], json_encode(['id_token' => $jwt(array_filter($claims, fn($v) => $v !== '__weg__')), 'access_token' => 'at-1']), null];
}
if ($url === 'https://auth.firma.example/application/o/userinfo/') {
return [200, json_encode($idp['userinfo'] ?? []), null];
}
return [404, '', null];
};
$begin = function () use (&$idp): array {
$_SESSION = [];
$res = authentik_login_start([]);
if (empty($res['redirect'])) return [null, [], $res];
parse_str((string) parse_url($res['redirect'], PHP_URL_QUERY), $q);
$idp['challenge'] = (string) ($q['code_challenge'] ?? '');
$idp['nonce'] = (string) ($q['nonce'] ?? '');
return [$res['redirect'], $q, $res];
};
$back = fn(array $query) => authentik_login_callback(['query' => $query]);
// Start
[$redirect, $q] = $begin();
$check($redirect !== null && str_starts_with($redirect, 'https://auth.firma.example/application/o/authorize/?'), 'Start leitet zu authentik');
$check(($q['response_type'] ?? '') === 'code' && ($q['client_id'] ?? '') === 'deskhub-client' && ($q['scope'] ?? '') === 'openid email profile' && ($q['code_challenge_method'] ?? '') === 'S256', 'Anfrage enthält Code-Ablauf, Client-ID, Bereiche und PKCE');
$check(strlen((string) ($q['state'] ?? '')) === 32 && strlen((string) ($q['nonce'] ?? '')) === 32 && strlen((string) ($q['code_challenge'] ?? '')) >= 43, 'Zustand, Nonce und Prüfwert sind zufällig und lang genug');
$check(($q['redirect_uri'] ?? '') === authentik_redirect_uri() && str_ends_with(authentik_redirect_uri(), '/staff/plugin_login.php?p=authentik&e=callback'), 'Die Rückkehr-Adresse stimmt');
[, $q2] = $begin();
$check($q2['state'] !== $q['state'] && $q2['nonce'] !== $q['nonce'], 'Jeder Start erzeugt neue Werte');
// Erfolgreiche Anmeldung
[, $q] = $begin();
$ok = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(($ok['staff_id'] ?? 0) === $anna && ($ok['via'] ?? '') === 'authentik', 'Erfolgreiche Rückkehr meldet den Mitarbeiter an (E-Mail ohne Beachtung der Groß-/Kleinschreibung): ' . json_encode($ok));
$check(!isset($_SESSION['authentik']), 'Die Sitzungswerte sind danach gelöscht');
$again = $back(['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($again['error']) && !isset($again['staff_id']), 'Dieselbe Rückkehr funktioniert nicht ein zweites Mal');
// Fehlerfälle
$fail = function (string $what, array $claims, array $queryOverride = [], ?callable $tweak = null) use (&$idp, $begin, $back, $check) {
$idp['claims'] = $claims; $idp['userinfo'] = null;
if ($tweak) $tweak();
[, $q] = $begin();
$res = $back($queryOverride + ['code' => 'gueltiger-code', 'state' => $q['state']]);
$check(isset($res['error']) && !isset($res['staff_id']), $what . ': ' . json_encode($res, JSON_UNESCAPED_UNICODE));
$idp['claims'] = []; $idp['issuerOverride'] = null; $idp['tokenStatus'] = 200;
return $res;
};
$fail('falscher Zustand (state) wird abgelehnt', [], ['state' => 'falsch']);
$fail('fehlender Code wird abgelehnt', [], ['code' => '']);
$fail('ungültiger Code wird abgelehnt', [], ['code' => 'geklaut']);
$fail('falsche Nonce wird abgelehnt', ['nonce' => 'fremde-nonce']);
$fail('falscher Aussteller wird abgelehnt', ['iss' => 'https://boese.example/o/x/']);
$fail('falsche Zielgruppe (aud) wird abgelehnt', ['aud' => 'andere-app']);
$fail('Zielgruppe als Liste ohne uns wird abgelehnt', ['aud' => ['a', 'b']]);
$fail('falscher Berechtigter (azp) wird abgelehnt', ['aud' => ['deskhub-client', 'x'], 'azp' => 'x']);
$fail('abgelaufenes Token wird abgelehnt', ['exp' => time() - 600]);
$fail('E-Mail nicht bestätigt wird bei fremder Kennung abgelehnt', ['email_verified' => false, 'sub' => 'u-unv']);
$fail('fehlende E-Mail wird abgelehnt', ['email' => '__weg__']);
$fail('ungültige E-Mail wird abgelehnt', ['email' => 'kein-mail']);
$fail('Fehler von authentik (error=access_denied) wird abgelehnt', [], ['error' => 'access_denied