← Volver al portfolio

Seguridad · Proyecto 01

Integración Single Sign-On

Autenticación centralizada entre sistemas: el usuario inicia sesión una sola vez en el proveedor de identidad y accede de forma segura a todas las aplicaciones internas, cada una con sus propios permisos.

Rol
Integración y desarrollo
Stack
PHP · OAuth 2.0 / OpenID Connect · JWT
Tipo
Demo interactiva

ⓘ Demo con datos ficticios, inspirada en mi trabajo real integrando SSO. Funciona entera en el navegador.

Simulador SSO

Inicia sesión una vez en Portal ID y abre las aplicaciones sin volver a escribir la contraseña.

🕒 --:--:--

Flujo OpenID Connect

En espera

    Inspector de token Sin token

    JWT real firmado en tu navegador con HMAC-SHA256 (Web Crypto)

    Cómo funciona

    1

    Una identidad central

    Las aplicaciones no guardan contraseñas: delegan el inicio de sesión en el proveedor de identidad (IdP), que crea una sesión SSO propia con una cookie segura.

    2

    Redirección y token firmado

    Cuando abres una app, te redirige al IdP. Si ya hay sesión, el IdP emite al momento un id_token JWT firmado con tu identidad y tus roles, dirigido solo a esa app.

    3

    Validación y permisos

    La app comprueba firma, emisor, audiencia, caducidad y nonce, y después autoriza según los roles. Al cerrar sesión, el IdP avisa a todas las apps (single logout).

    Código destacado

    Callback OpenID Connect en la aplicación (proveedor de servicio) escrita en PHP: canjea el código por el token y lo valida antes de crear la sesión local.

    sso/callback.php
    <?php
    declare(strict_types=1);
    
    use Firebase\JWT\JWT;
    use Firebase\JWT\JWK;
    
    session_start();
    $cfg = require __DIR__ . '/config.php'; // issuer, client_id, client_secret, redirect_uri
    
    // 1. Protección CSRF: el state debe coincidir con el que guardamos antes de redirigir
    if (!hash_equals($_SESSION['oidc_state'] ?? '', $_GET['state'] ?? '')) {
        http_response_code(400);
        exit('Estado OIDC no válido');
    }
    
    // 2. Canjear el código de autorización por los tokens (canal servidor a servidor)
    $res = httpPost($cfg['issuer'] . '/token', [
        'grant_type'    => 'authorization_code',
        'code'          => $_GET['code'],
        'redirect_uri'  => $cfg['redirect_uri'],
        'code_verifier' => $_SESSION['pkce_verifier'],
    ], basicAuth($cfg['client_id'], $cfg['client_secret']));
    
    // 3. Validar el id_token: firma (JWKS del IdP), caducidad y claims
    try {
        JWT::$leeway = 30; // tolerancia de reloj en segundos
        $keys   = JWK::parseKeySet(cachedJwks($cfg['issuer'] . '/jwks'));
        $claims = JWT::decode($res['id_token'], $keys); // lanza excepción si firma o exp fallan
    } catch (\Throwable $e) {
        error_log('SSO: token rechazado - ' . $e->getMessage());
        http_response_code(401);
        exit('No se pudo validar tu identidad');
    }
    
    $audOk   = in_array($cfg['client_id'], (array) $claims->aud, true);
    $nonceOk = hash_equals($_SESSION['oidc_nonce'], $claims->nonce ?? '');
    if ($claims->iss !== $cfg['issuer'] || !$audOk || !$nonceOk) {
        http_response_code(401);
        exit('Token emitido para otro destinatario');
    }
    
    // 4. Autorización por rol y sesión local (nuevo id para evitar fijación de sesión)
    if (!array_intersect($cfg['allowed_roles'], $claims->roles ?? [])) {
        http_response_code(403);
        exit('Tu rol no tiene acceso a esta aplicación');
    }
    
    session_regenerate_id(true);
    unset($_SESSION['oidc_state'], $_SESSION['oidc_nonce'], $_SESSION['pkce_verifier']);
    $_SESSION['user'] = [
        'sub'   => $claims->sub,
        'name'  => $claims->name,
        'roles' => $claims->roles,
        'sid'   => $claims->sid, // necesario para el back-channel logout
    ];
    
    header('Location: ' . ($_SESSION['return_to'] ?? '/'));