OpenID Connect (OIDC) extension pro Nette Framework s podporou Keycloak a dalších OIDC providerů.
Knihovna integruje facile-it/php-openid-client do Nette aplikací a poskytuje jednoduché API pro autentizaci přes OpenID Connect, včetně podpory pro backchannel logout (Single Sign-Out).
- PHP 8.1 nebo vyšší
- Nette Framework 3.1+
- OpenID Connect provider (např. Keycloak)
composer require sitmpcz/oidcZaregistrujte extension v config.neon:
extensions:
openid: Sitmpcz\oidc\DI\OpenIDExtension
openid:
issuerUrl: %env.ISSUER_URL% # URL OIDC providera
clientId: %env.CLIENT_ID% # Client ID z OIDC providera
clientSecret: %env.CLIENT_SECRET% # Client Secret z OIDC providera
redirectUri: "/sign/callback" # povinné
postLogoutRedirectUri: "/" # volitelné
backchannelLogoutUri: "/sign/out-slo" # volitelné
scopes: [openid, profile, email] # volitelné
idTokenSignedResponseAlg: EdDSA # volitelné, výchozí EdDSA| Parametr | Povinný | Popis |
|---|---|---|
issuerUrl |
Ano | URL vašeho OIDC providera (např. https://keycloak.example.com/realms/myrealm) |
clientId |
Ano | Client ID z konfigurace OIDC providera |
clientSecret |
Ano | Client Secret z konfigurace OIDC providera. Knihovna podporuje pouze confidential klienty — pro public klienta by bylo potřeba PKCE, které implementované není |
redirectUri |
Ano | URI pro callback po přihlášení. Musí odpovídat Valid redirect URIs u klienta v Keycloaku |
postLogoutRedirectUri |
Ne | URI pro přesměrování po odhlášení. Výchozí: / |
backchannelLogoutUri |
Ne | URI endpoint pro backchannel logout (Single Sign-Out) |
scopes |
Ne | OIDC scopes. Výchozí: [openid, profile, email] |
idTokenSignedResponseAlg |
Ne | Očekávaný podpisový algoritmus ID tokenu. Výchozí: EdDSA, protože tak podepisuje realm, pro který je knihovna primárně určená. Keycloak má ve výchozím stavu RS256, takže proti jinému realmu je nutné to přepsat. Musí odpovídat přesně, jinak přihlášení skončí na Invalid token provided |
Relativní vs. Absolutní URL:
Všechny URI parametry podporují relativní cesty (např. /sign/callback). Knihovna automaticky doplní schéma, doménu a port z aktuálního HTTP requestu. Můžete také používat absolutní URL.
Pokud běžíte za reverse proxy (nginx, Apache) nebo v Kubernetes Ingress, knihovna automaticky detekuje:
X-Forwarded-Proto- pro detekci HTTPSX-Forwarded-Host- pro správný hostnameX-Forwarded-Port- pro správný port
Ujistěte se, že vaše proxy tyto hlavičky správně nastavuje.
Pozor: knihovna těmto hlavičkám věří bez ověření, že request skutečně přišel od tvé proxy. Kdokoli je může poslat a ovlivnit tím sestavené
redirect_uri. Nastav trusted proxy (Nette\Http\RequestFactory::setProxy()), nebo zadej URI parametry jako absolutní URL — pak se hlavičky nepoužijí vůbec. Viz Zabezpečení.
Příklad pro nginx:
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;Příklad pro Kubernetes Ingress: Většina Ingress controllers (nginx-ingress, Traefik) nastavuje tyto hlavičky automaticky.
<?php
declare(strict_types=1);
namespace App\Presenters;
use Nette\Application\Attributes\Requires;
use Nette\Application\Responses\TextResponse;
use Nette\Application\UI\Presenter;
use Sitmpcz\oidc\Security\OpenIDClientService;
use Tracy\Debugger;
final class SignPresenter extends Presenter
{
public function __construct(
private OpenIDClientService $oidc
) {}
public function actionLogin(): void
{
$this->redirectUrl($this->oidc->getAuthorizationUrl());
}
public function actionCallback(): void
{
try {
$userinfo = $this->oidc->handleCallback();
} catch (\RuntimeException $e) {
// Vypršelá session, nesouhlasící state, login otevřený ve dvou tabech.
// Nepřesměrovávej zpět na actionLogin - hned by spustil nový OIDC flow
// a při trvale nepřenesené cookie by vznikla redirect smyčka.
Debugger::log($e, 'oidc');
$this->flashMessage('Přihlášení vypršelo, zkuste to prosím znovu.', 'danger');
$this->redirect('Homepage:');
}
// Použijte eventuelně vlastní Authenticator pro přiřazení rolí a oprávnění
$this->getUser()->login($userinfo['preferred_username']);
$this->redirect('Homepage:');
}
public function actionLogout(): void
{
// Získat ID token před odhlášením (nutný pro id_token_hint v OIDC logout URL)
$idToken = $this->oidc->getIdToken();
// Vyčistit lokální session
$this->oidc->logout();
$this->getUser()->logout();
// Přesměrovat na OIDC provider pro globální odhlášení
$this->redirectUrl($this->oidc->getLogoutUrl($idToken));
}
/**
* Endpoint pro backchannel logout - volá ho Keycloak při odhlášení z jiné aplikace
* URL: /sign/out-slo
*
* POZOR: handleBackchannelLogout() vidí jen session aktuálního requestu.
* Keycloak volá tenhle endpoint server-to-server bez session cookie, takže
* ve většině nasazení nebude co spárovat. Tahle varianta je použitelná jen
* tam, kde si session dohledáš sám - viz sekce o Redis sessions níže.
*/
#[Requires(methods: 'POST')]
public function actionOutSlo(): void
{
$logoutToken = $this->getHttpRequest()->getPost('logout_token');
if (!is_string($logoutToken) || $logoutToken === '') {
$this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
$this->sendResponse(new TextResponse(''));
}
try {
$success = $this->oidc->handleBackchannelLogout($logoutToken);
if ($success) {
$this->getUser()->logout(true);
}
} catch (\RuntimeException $e) {
// Detaily verifikace tokenu nikdy neposílej volajícímu - jen zaloguj
Debugger::log($e, 'oidc');
$this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
$this->sendResponse(new TextResponse(''));
}
// OIDC specifikace vyžaduje HTTP 200 bez obsahu
$this->sendResponse(new TextResponse(''));
}
}Pokud používáte Redis pro ukládání sessions, backchannel logout vyžaduje speciální přístup, protože Keycloak nemá přímý přístup k vaší aktivní session - musíte vyhledat session v Redis podle sid (session ID) z logout tokenu.
config/redis.neon:
extensions:
redis: Contributte\Redis\DI\RedisExtension
redis:
debug: %debugMode%
connection:
default:
uri: tcp://redis:6379
sessions: false
storage: true
options: ['parameters': ['database': 0]]
session:
uri: tcp://redis:6379
sessions: true # Redis jako session handler
storage: false
options: ['parameters': ['database': 1]] # Oddělená databáze pro sessionsconfig/common.neon:
extensions:
openid: Sitmpcz\oidc\DI\OpenIDExtension
openid:
issuerUrl: %env.ISSUER_URL%
clientId: %env.CLIENT_ID%
clientSecret: %env.CLIENT_SECRET%
redirectUri: "/sign/callback"
postLogoutRedirectUri: "/sign/in"
backchannelLogoutUri: "/sign/out-slo"
scopes: [openid, profile, email]
services:
# SignPresenter s explicitním Redis klientem pro backchannel logout
- App\Presenters\SignPresenter(
redisSession: @redis.connection.session.client
)<?php
declare(strict_types=1);
namespace App\Presenters;
use Nette\Application\Attributes\Requires;
use Nette\Application\Responses\TextResponse;
use Nette\Application\UI\Presenter;
use Sitmpcz\oidc\Security\OpenIDClientService;
use Predis\ClientInterface as RedisClient;
use Tracy\Debugger;
final class SignPresenter extends Presenter
{
public function __construct(
private OpenIDClientService $oidc,
private RedisClient $redisSession // Redis klient pro sessions (databáze 1)
) {}
public function actionLogin(): void
{
$this->redirectUrl($this->oidc->getAuthorizationUrl());
}
public function actionCallback(): void
{
try {
$userinfo = $this->oidc->handleCallback();
} catch (\RuntimeException $e) {
Debugger::log($e, 'oidc');
$this->flashMessage('Přihlášení vypršelo, zkuste to prosím znovu.', 'danger');
$this->redirect('Homepage:');
}
$this->getUser()->login($userinfo['preferred_username']);
$this->redirect('Homepage:');
}
public function actionOut(): void
{
$idToken = $this->oidc->getIdToken();
// Odhlásit lokálně
$this->getUser()->logout();
$this->oidc->logout();
// Zničit celou session včetně dat v Redis
$this->session->destroy();
// Přesměrovat na OIDC logout endpoint (Single Sign-Out)
$this->redirectUrl($this->oidc->getLogoutUrl($idToken));
}
/**
* Backchannel logout endpoint - vyhledává sessions v Redis podle sid/sub
* URL: /sign/out-slo
*/
#[Requires(methods: 'POST')]
public function actionOutSlo(): void
{
$logoutToken = $this->getHttpRequest()->getPost('logout_token');
if (!is_string($logoutToken) || $logoutToken === '') {
$this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
$this->sendResponse(new TextResponse(''));
}
// KLÍČOVÉ: ověř podpis a claims tokenu PŘED jakoukoli manipulací se session.
// Bez toho je endpoint neautentizovaná mazačka session pro kohokoli.
try {
$claims = $this->oidc->verifyLogoutToken($logoutToken);
} catch (\RuntimeException $e) {
// Nevracej $e->getMessage() - leakuje detaily verifikace tokenu
Debugger::log($e, 'oidc');
$this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
$this->sendResponse(new TextResponse(''));
}
$sid = $claims['sid'] ?? null;
$sub = $claims['sub'] ?? null;
// Scan all session keys in Redis using SCAN (non-blocking, unlike KEYS)
$cursor = '0';
do {
[$cursor, $keys] = $this->redisSession->scan($cursor, ['COUNT' => 100]);
foreach ($keys as $sessionKey) {
$sessionData = $this->redisSession->get($sessionKey);
if (!$sessionData) {
continue;
}
// Extract idToken from serialized session data
// Format: oidc|a:3:{s:8:"userInfo";a:...;s:7:"idToken";s:NNN:"...";
if (!preg_match('/s:7:"idToken";s:\d+:"([^"]+)"/', $sessionData, $matches)) {
continue;
}
// Token z vlastního storage, podpis už ověřený při přihlášení
$idPayload = OpenIDClientService::decodeJwtPayload($matches[1]);
if ($idPayload === null) {
continue;
}
// sid má přednost; sub se použije jen když sid v logout tokenu není
$match = $sid !== null
? ($idPayload['sid'] ?? null) === $sid
: ($sub !== null && ($idPayload['sub'] ?? null) === $sub);
if ($match) {
$this->redisSession->del($sessionKey);
}
}
} while ($cursor !== '0');
// Spec vyžaduje prázdnou 200 - počet smazaných session neposílej,
// byl by to oracle na to, kdo je právě přihlášený
$this->sendResponse(new TextResponse(''));
}
}-
Oddělené databáze: Používejte samostatnou Redis databázi pro sessions (např. databáze 1) oddělenou od cache (databáze 0)
-
Backchannel logout vyžaduje vyhledávání: Na rozdíl od standardních Nette sessions, kde je aktivní session dostupná v kontextu requestu, u backchannel logout musíte:
- Projít všechny session klíče v Redis
- Deserializovat session data
- Najít ID token v sekci
oidc - Porovnat
sidnebosubz logout tokenu s ID tokenem v session - Smazat odpovídající session z Redis
-
Výkon: Pro velký počet aktivních sessions může být vyhledávání pomalé. Zvažte:
- Index sessions podle
sidv samostatné Redis struktuře - TTL pro Redis session klíče odpovídající session expiraci
- Monitoring počtu aktivních sessions
- Index sessions podle
-
Bezpečnost — nejdůležitější bod celé sekce:
verifyLogoutToken()musí být zavoláno před vyhledáváním v Redis asid/subse musí brát z jeho návratové hodnoty, ne z ručně dekódovaného JWT. Endpoint je veřejný a neautentizovaný; bez ověření podpisu maže session komukoli, kdo pošle vlastní nepodepsaný token. -
Nikdy nepoužívej
KEYS *— blokuje celý Redis po dobu průchodu keyspace, takže neautentizovaný POST na tenhle endpoint se stává DoS na všechny session. VždySCAN, jako v příkladu. -
Neposílej v odpovědi počet smazaných session — útočník by iterováním
subzjistil, kdo je právě přihlášený. Spec chce prázdnou 200.
Vrací URL pro přesměrování na přihlašovací stránku OIDC providera. Vygeneruje state a nonce, uloží je do session a přidá do URL. Musí být zavoláno ve stejné session, ve které pak proběhne handleCallback().
Zpracuje callback z OIDC providera a vrátí informace o uživateli. Před vydáním dat ověří state a nonce a vygeneruje nové session ID. Hodí RuntimeException, pokud v session není čekající authorization request, pokud state nesouhlasí nebo pokud nonce v ID tokenu neodpovídá — na callback tedy nelze přijít „zvenčí", flow musí vždy začít voláním getAuthorizationUrl().
Obnoví tokeny pomocí uloženého refresh tokenu a aktualizuje v session refreshToken a idToken. Vrací true při úspěchu, false když v session žádný refresh token není nebo ho provider odmítl. Když provider při rotaci nový refresh token nevrátí, ponechá se ten stávající.
Vrací end-session URL OIDC providera. Při zadání ID tokenu ho přidá jako id_token_hint, což provideru umožní odhlásit konkrétní session bez dotazu uživateli. Hodí RuntimeException, pokud provider end_session_endpoint v discovery dokumentu nemá.
Vyčistí lokální session (userInfo, refreshToken, idToken, rozpracovaný state/nonce) a vygeneruje nové session ID.
Vrací uložený ID token ze session, pokud existuje.
Zpracuje backchannel logout požadavek z OIDC providera (např. Keycloak). Validuje JWT logout token a odhlásí lokální session, pokud token odpovídá aktuálnímu uživateli. Vrací true pokud byla session odhlášena. Vidí jen session aktuálního requestu — provider volá endpoint server-to-server bez cookie, takže při odděleném session storage použij verifyLogoutToken().
Ověří podpis logout tokenu proti JWKS providera a jeho claims podle spec (events, zákaz nonce, přítomnost sid/sub) a vrátí verifikované claims. Se session nijak nemanipuluje. Použij ji, když si session hledáš sám (typicky napříč Redisem) — vrácené claims jsou důvěryhodné, ručně dekódovaný JWT nikdy. Hodí RuntimeException, pokud je token nevalidní.
Statická pomocná metoda: rozparsuje payload JWT bez ověření podpisu. Používej jen na tokeny, které už jsi ověřil, nebo na tokeny z vlastní session (např. ID token uložený v Redisu). Na vstup od klienta nikdy — k tomu je verifyLogoutToken().
Backchannel logout umožňuje OIDC provideru automaticky odhlásit uživatele z vaší aplikace, když se odhlásí z jiné aplikace připojené ke stejnému provideru.
- V Keycloak administraci přejděte na Client Settings vašeho klienta
- Nastavte Backchannel Logout URL:
https://vase-domena.cz/sign/out-slo - Zapněte Backchannel Logout Session Required
Tip: V config.neon stačí uvést relativní cestu (backchannelLogoutUri: "/sign/out-slo"), knihovna automaticky sestaví plnou URL.
- Uživatel se odhlásí z aplikace A připojené ke Keycloak
- Keycloak pošle POST požadavek na backchannel logout endpoint aplikace B
- Aplikace B validuje JWT
logout_tokena odhlásí uživatele - Uživatel je nyní odhlášen ze všech aplikací (Single Sign-Out)
Token je validován podle OIDC Back-Channel Logout specifikace. Session se páruje podle sid (session ID); sub (subject/user ID) se použije jen tehdy, když token sid neobsahuje — jinak by odhlášení jedné session shodilo všechny session daného uživatele.
Co knihovna dělá:
state— každý authorization request je svázán s konkrétní session. Chrání proti CSRF a authorization code injection (podstrčení cizíhocode).nonce— ID token musí obsahovatnonceodpovídající session. Chrání proti replay ID tokenu. Kontrola je explicitní: token beznonceje odmítnut, nejen token s nesprávnýmnonce.- Připnutý podpisový algoritmus —
id_token_signed_response_algse posílá v client metadatech, takže verifikátor si zaregistrujeAlgorithmCheckera přijme jen ten jeden algoritmus. Bez toho žádný allow-list neplatí, algoritmus si vybírá header tokenu a mezi podporovanými je inone, jehožverify()vracítruepro prázdný podpis bez kontroly typu klíče. Krylo to jen to, že JWKS obvykle u klíčůalguvádí. Platí i pro backchannel logout token, ověřuje se stejným builderem. - Regenerace session ID při přihlášení a odhlášení — ochrana proti session fixation.
Hodnota idTokenSignedResponseAlg musí přesně odpovídat tomu, čím provider podepisuje. Ověříš to na JWKS endpointu:
curl -s https://keycloak.example.com/realms/sitmp/protocol/openid-connect/certs \
| jq '.keys[] | select(.use == "sig") | {kid, kty, alg, crv}'V Keycloaku je to Realm Settings → Tokens → Default Signature Algorithm, plus per-client override v Client → Advanced → ID Token Signature Algorithm, který má přednost. Zkontroluj obojí.
Omezení u EdDSA: web-token/jwt-framework podporuje pouze křivku Ed25519. Když má klíč v JWKS "crv": "Ed448", knihovna ho neověří za žádné konfigurace — na to je potřeba realm přepnout na Ed25519 nebo na RSA/EC algoritmus.
Co knihovna nedělá a co si musíš zajistit sám:
- PKCE není implementováno. Pro confidential klienta (se
clientSecret) je náhradounonce, viz RFC 9700. Public klient touto knihovnou podporovaný není, proto jeclientSecretpovinný. - Hlavičky
X-Forwarded-*se berou bez omezení.buildAbsoluteUrl()jim věří, takže musíš mít nakonfigurovanou trusted proxy (Nette\Http\RequestFactory::setProxy()), nebo — bezpečněji — zadatredirectUri,postLogoutRedirectUriabackchannelLogoutUrijako absolutní URL. - Backchannel logout nemá ochranu proti replay.
jtise nikam neukládá, odposlechnutýlogout_tokenlze přehrávat do jeho expirace (vynucené odhlašování). - Chybové hlášky neposílej klientovi. Zprávy z výjimek obsahují detaily verifikace tokenu; na backchannel endpointu vracej jen prázdné 400.
- Authorization code flow s
stateanoncesvázanými se session - Ověření ID tokenu proti JWKS providera s připnutým algoritmem
- Regenerace session ID při přihlášení a odhlášení
- Front-channel a backchannel logout, včetně ověření logout tokenu
- Obnova tokenů přes refresh token (voláním
refreshToken(), ne automaticky) - Správa session v Nette session storage
- Automatické sestavování absolutních URL z relativních cest
- Podpora reverse proxy a Kubernetes Ingress
GPL-3.0-or-later — viz LICENSE.