sitmpcz/oidc

There is no license information available for the latest version (v1.0) of this package.

Integration oidc client to Nette

Maintainers

Details

github.com/sitmpcz/oidc

Source

Issues

Installs: 47

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/sitmpcz/oidc

v1.0 2026-02-20 08:21 UTC

This package is not auto-updated.

Last update: 2026-02-20 09:11:34 UTC


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"            # volitelné
    postLogoutRedirectUri: "/"               # volitelné
    backchannelLogoutUri: "/sign/out-slo"    # volitelné
    scopes: [openid, profile, email]         # volitelné

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
redirectUri Ne URI pro callback po přihlášení. Pokud neuvedete, použije se aktuální URL z requestu
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]

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 HTTPS
  • X-Forwarded-Host - pro správný hostname
  • X-Forwarded-Port - pro správný port

Ujistěte se, že vaše proxy tyto hlavičky správně nastavuje.

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\UI\Presenter;
use Sitmpcz\oidc\Security\OpenIDClientService;

final class SignPresenter extends Presenter
{
    public function __construct(
        private OpenIDClientService $oidc
    ) {}

    public function actionLogin(): void
    {
        $this->redirectUrl($this->oidc->getAuthorizationUrl());
    }

    public function actionCallback(): void
    {
        $userinfo = $this->oidc->handleCallback();
        $this->getUser()->login($userinfo['preferred_username']);
        $this->redirect('Homepage:');
    }

    public function actionLogout(): void
    {
        // Získat ID token pro správné odhlášení z OIDC providera
        $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
     */
    public function actionOutSlo(): void
    {
        $logoutToken = $this->getHttpRequest()->getPost('logout_token');

        if (!$logoutToken) {
            $this->error('Missing logout_token', 400);
        }

        try {
            $success = $this->oidc->handleBackchannelLogout($logoutToken);

            if ($success) {
                // Odhlásit uživatele z Nette
                $this->getUser()->logout(true);
            }

            // OIDC specifikace vyžaduje HTTP 200 bez obsahu
            $this->sendResponse(new \Nette\Application\Responses\TextResponse(''));
        } catch (\RuntimeException $e) {
            $this->error($e->getMessage(), 400);
        }
    }
}

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\UI\Presenter;
use Sitmpcz\oidc\Security\OpenIDClientService;
use Predis\ClientInterface as RedisClient;

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
    {
        $userinfo = $this->oidc->handleCallback();
        $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
     */
    public function actionOutSlo(): void
    {
        $this->getHttpResponse()->setContentType('application/json');

        try {
            $logoutToken = $this->getHttpRequest()->getPost('logout_token');

            if (!$logoutToken) {
                $this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
                $this->sendJson(['error' => 'logout_token parameter is required']);
            }

            // Dekóduj logout token a získej sid/sub
            $parts = explode('.', $logoutToken);
            if (count($parts) !== 3) {
                $this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
                $this->sendJson(['error' => 'Invalid logout token format']);
            }

            $payload = json_decode(base64_decode(strtr($parts[1], '-_', '+/')), true);
            $sid = $payload['sid'] ?? null;
            $sub = $payload['sub'] ?? null;

            if (!$sid && !$sub) {
                $this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
                $this->sendJson(['error' => 'logout_token must contain sid or sub']);
            }

            // Najdi všechny session klíče v Redis
            $sessionKeys = $this->redisSession->keys('*');
            $deletedSessions = 0;

            foreach ($sessionKeys as $sessionKey) {
                $sessionData = $this->redisSession->get($sessionKey);
                if (!$sessionData) {
                    continue;
                }

                // Zkontroluj, zda session obsahuje OIDC data
                if (strpos($sessionData, 'oidc') === false) {
                    continue;
                }

                // Extrahuj idToken ze serializovaných session dat
                // Formát: oidc|a:3:{s:8:"userInfo";a:...;s:7:"idToken";s:NNN:"...";
                if (preg_match('/s:7:"idToken";s:\d+:"([^"]+)"/', $sessionData, $matches)) {
                    $idToken = $matches[1];

                    // Dekóduj ID token
                    $idParts = explode('.', $idToken);
                    if (count($idParts) === 3) {
                        $idPayload = json_decode(base64_decode(strtr($idParts[1], '-_', '+/')), true);
                        $sessionSid = $idPayload['sid'] ?? null;
                        $sessionSub = $idPayload['sub'] ?? null;

                        // Porovnej sid nebo sub
                        if (($sid && $sessionSid === $sid) || ($sub && $sessionSub === $sub)) {
                            // Smaž tuto session z Redis
                            $this->redisSession->del($sessionKey);
                            $deletedSessions++;
                        }
                    }
                }
            }

            if ($deletedSessions > 0) {
                $this->getHttpResponse()->setCode(\Nette\Http\Response::S200_OK);
                $this->sendJson([
                    'status' => 'logged_out',
                    'deleted_sessions' => $deletedSessions
                ]);
            } else {
                $this->getHttpResponse()->setCode(\Nette\Http\Response::S200_OK);
                $this->sendJson(['status' => 'no_matching_session']);
            }
        } catch (\Exception $e) {
            $this->getHttpResponse()->setCode(\Nette\Http\Response::S400_BadRequest);
            $this->sendJson(['error' => $e->getMessage()]);
        }
    }
}

Důležité poznámky pro Redis

  1. Oddělené databáze: Používejte samostatnou Redis databázi pro sessions (např. databáze 1) oddělenou od cache (databáze 0)

  2. 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 sid nebo sub z logout tokenu s ID tokenem v session
    • Smazat odpovídající session z Redis
  3. Výkon: Pro velký počet aktivních sessions může být vyhledávání pomalé. Zvažte:

    • Index sessions podle sid v samostatné Redis struktuře
    • TTL pro Redis session klíče odpovídající session expiraci
    • Monitoring počtu aktivních sessions
  4. Bezpečnost: Backchannel endpoint neověřuje JWT logout token - v produkčním prostředí zvažte přidání validace tokenu pomocí OpenIDClientService::handleBackchannelLogout() před vyhledáváním v Redis.

Dostupné metody

getAuthorizationUrl(): string

Vrací URL pro přesměrování na přihlašovací stránku OIDC providera.

handleCallback(): array

Zpracuje callback z OIDC providera a vrátí informace o uživateli.

refreshToken(): bool

Obnoví access token pomocí refresh tokenu. Vrací true při úspěchu, false při selhání.

getLogoutUrl(?string $idToken = null): string

Vrací URL pro odhlášení z OIDC providera. Při zadání ID tokenu poskytuje lepší single sign-out.

logout(): void

Vyčistí lokální session (userInfo, refreshToken, idToken).

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.

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

  1. V Keycloak administraci přejděte na Client Settings vašeho klienta
  2. Nastavte Backchannel Logout URL: https://vase-domena.cz/sign/out-slo
  3. 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

  1. Uživatel se odhlásí z aplikace A připojené ke Keycloak
  2. Keycloak pošle POST požadavek na backchannel logout endpoint aplikace B
  3. Aplikace B validuje JWT logout_token a odhlásí uživatele
  4. Uživatel je nyní odhlášen ze všech aplikací (Single Sign-Out)

Token je validován podle OIDC Back-Channel Logout specifikace a session je spárována podle sid (session ID) nebo sub (subject/user ID).

Klíčové vlastnosti

  • Automatické sestavování absolutních URL z relativních cest
  • Podpora reverse proxy a Kubernetes Ingress
  • Front-channel a backchannel logout
  • Správa session v Nette session storage
  • Automatická obnova tokenů přes refresh token
  • JWT validace podle OIDC standardů

Licence

MIT