sitmpcz / oidc
Integration oidc client to Nette
Requires
- php: >=8.1
- contributte/psr7-http-message: ^0.10.0
- facile-it/php-openid-client: ^0.3.5
- nette/di: ^3.1
- nette/http: ^3.1
- web-token/jwt-framework: ^3.4
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
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).
Požadavky
- PHP 8.1 nebo vyšší
- Nette Framework 3.1+
- OpenID Connect provider (např. Keycloak)
Instalace
composer require sitmpcz/oidc
Konfigurace
Zaregistrujte 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é idTokenSigningAlgs: [RS256, EdDSA] # volitelné, výchozí všechny asymetrické
Parametry konfigurace
| 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] |
idTokenSigningAlgs |
Ne | Allow-list podpisových algoritmů ID tokenu (a backchannel logout tokenu). Výchozí: všechny asymetrické — RS256/384/512, PS256/384/512, ES256/384/512, EdDSA — takže typicky není potřeba nic nastavovat. Seznam se navíc zúží na průnik s id_token_signing_alg_values_supported z discovery (.well-known/openid-configuration); discovery ho jen zužuje, nikdy nerozšíří. none a HS* povolit nejde, konfigurace s nimi neprojde validací. Zúžit seznam má smysl, když víš, čím provider podepisuje |
idTokenSignedResponseAlg |
Ne | Zastaralé. Jeden povolený algoritmus, ekvivalent idTokenSigningAlgs: [<alg>]. Nelze kombinovat s idTokenSigningAlgs |
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.
Podpora Reverse Proxy
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.
Použití v presenteru
<?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('')); } }
Použití s Redis sessions (contributte/redis)
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.
Konfigurace Redis
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 sessions
config/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 )
Presenter s Redis backchannel logout
<?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('')); } }
Důležité poznámky pro Redis
-
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.
Dostupné metody
getAuthorizationUrl(): string
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().
handleCallback(): array
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().
refreshToken(): bool
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í.
getLogoutUrl(?string $idToken = null): string
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á.
logout(): void
Vyčistí lokální session (userInfo, refreshToken, idToken, rozpracovaný state/nonce) a vygeneruje nové session ID.
getIdToken(): ?string
Vrací uložený ID token ze session, pokud existuje.
handleBackchannelLogout(string $logoutToken): bool
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().
verifyLogoutToken(string $logoutToken): array
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í.
decodeJwtPayload(string $jwt): ?array
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 (Single Sign-Out)
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.
Konfigurace v Keycloak
- 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.
Jak to funguje
- 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.
Zabezpečení
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.- Allow-list podpisových algoritmů — povolené jsou jen asymetrické algoritmy (
idTokenSigningAlgs). Verifier (AlgAllowListIdTokenVerifier) přečtealgz hlavičky tokenu, odmítne ho, pokud není v allow-listu, a jinak ho upstream verifieru připne přeswithExpectedAlg(), takže ten si zaregistrujeAlgorithmChecker. Bez připnutého algoritmu si ho vybírá header tokenu a mezi podporovanými je inone, jehožverify()vracítruepro prázdný podpis bez kontroly typu klíče, aHS*, podepsanéclient_secret, který zná i klient. Mezi asymetrickými algoritmy výběr bezpečný je: podpis bez privátního klíče providera nevyrobíš ajwt-frameworkkontroluje, že typ klíče z JWKS odpovídá algoritmu. 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.
Zjištění algoritmu u providera
Výchozí allow-list pokrývá všechny běžné algoritmy, takže tohle potřebuješ jen při zužování idTokenSigningAlgs nebo při ladění Invalid token provided. Čím provider podepisuje, ověříš 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.
Klíčové vlastnosti
- Authorization code flow s
stateanoncesvázanými se session - Ověření ID tokenu proti JWKS providera s allow-listem asymetrických algoritmů
- 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
Licence
GPL-3.0-or-later — viz LICENSE.