todos los sistemas operativostodo operativo

apenaloza.cl · bitácora

SCL (UTC−3) · v2026.11

~/blog/sso-keycloak-laravel-nextjs-openid-connect.md

SSO con Keycloak: un solo login para Laravel y Next.js con OpenID Connect

Cómo implementar inicio de sesión único (SSO) entre varios sistemas con Keycloak y OpenID Connect: despliegue en Docker, configuración del realm y los clientes, integración con Laravel y Next.js, cierre de sesión global, roles y los errores que más se repiten.

autor
Alejandro Peñaloza
Publicado
Lectura
5 min
Portada: SSO con Keycloak, un solo login para Laravel y Next.js con OpenID Connect, con una llave conectada a tres aplicaciones
En este artículo
  1. Conceptos mínimos
  2. Paso 1: levantar Keycloak con Docker
  3. Paso 2: configurar el realm
  4. Paso 3: registrar las aplicaciones como clientes
  5. Paso 4: Laravel con Socialite
  6. Paso 5: Next.js con Auth.js
  7. Cierre de sesión global
  8. Migrar usuarios que ya existen
  9. Errores comunes
  10. Conclusión

Cuando una empresa crece a punta de sistemas, el usuario termina con una contraseña distinta para cada uno: el ERP, el portal de clientes, la app de reportes, el panel de administración. Cada sistema con su tabla de usuarios, su recuperación de contraseña y sus permisos. Y cada vez que alguien deja la empresa, hay que acordarse de desactivarlo en todos.

La solución es el inicio de sesión único (SSO): un solo lugar donde la gente se autentica y desde donde todos los sistemas confían en esa identidad. En este post lo implemento con Keycloak, un servidor de identidad de código abierto, conectando una aplicación Laravel y otra Next.js.

Conceptos mínimos

  • OpenID Connect (OIDC): el protocolo estándar para autenticación, construido sobre OAuth 2.0. Tus aplicaciones no ven la contraseña; reciben un token firmado que dice quién es el usuario.
  • Proveedor de identidad (IdP): el servidor que autentica, en este caso Keycloak.
  • Realm: un espacio aislado en Keycloak con sus propios usuarios, roles y clientes. Normalmente uno por organización.
  • Cliente: cada aplicación que delega el login en Keycloak.
  • Flujo de código de autorización (authorization code): el flujo recomendado. La aplicación redirige al usuario a Keycloak, este vuelve con un código y la aplicación lo cambia por los tokens desde el servidor.
sequenceDiagram
    participant U as Usuario
    participant A as App Laravel
    participant K as Keycloak
    participant B as App Next.js
    U->>A: Entra a la app A
    A->>K: Redirige a /auth
    U->>K: Usuario y contraseña (+2FA)
    K-->>A: Código de autorización
    A->>K: Cambia código por tokens
    A-->>U: Sesión iniciada en A
    U->>B: Entra a la app B
    B->>K: Redirige a /auth
    K-->>B: Ya hay sesión: código sin pedir contraseña
    B-->>U: Sesión iniciada en B

La magia del SSO está en el segundo login: Keycloak ya tiene una sesión con el usuario, así que entrega el código a la aplicación B sin volver a pedir credenciales.

Paso 1: levantar Keycloak con Docker

Para producción, Keycloak necesita una base de datos propia y correr detrás de un proxy con HTTPS.

services:
  keycloak-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: ${KC_DB_PASSWORD}
    volumes:
      - kc-db:/var/lib/postgresql/data
    restart: unless-stopped

  keycloak:
    image: quay.io/keycloak/keycloak:26.0
    command: start
    environment:
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak
      KC_DB_USERNAME: keycloak
      KC_DB_PASSWORD: ${KC_DB_PASSWORD}
      KC_HOSTNAME: https://auth.midominio.cl
      KC_PROXY_HEADERS: xforwarded
      KC_HTTP_ENABLED: "true"
      KC_HEALTH_ENABLED: "true"
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_ADMIN_PASSWORD}
    ports:
      - "127.0.0.1:8080:8080"
    depends_on:
      - keycloak-db
    restart: unless-stopped

volumes:
  kc-db:

Detrás de Nginx o Traefik con certificado para auth.midominio.cl. Puntos clave:

  • KC_HOSTNAME debe ser la URL pública exacta. Si no coincide, los tokens salen con un issuer distinto y las aplicaciones los rechazan.
  • KC_PROXY_HEADERS: xforwarded hace que Keycloak confíe en los encabezados del proxy para saber que la conexión original era HTTPS. Asegúrate de que solo el proxy pueda llegar al puerto 8080.
  • La cuenta de administrador inicial es temporal: entra, crea un administrador definitivo con 2FA y elimina la inicial.
  • Para desarrollo local basta command: start-dev, sin base de datos externa.

Paso 2: configurar el realm

En la consola de administración:

  1. Crea un realm (por ejemplo, empresa). No uses el realm master para tus aplicaciones: es solo para administrar Keycloak.
  2. En Realm settings → Login, decide si los usuarios pueden registrarse solos, recuperar contraseña y si el login es por correo.
  3. En Authentication → Policies, define la política de contraseñas y activa el OTP (o exígelo para roles administrativos).
  4. Configura el SMTP para que funcionen la verificación de correo y la recuperación de contraseña.
  5. Crea los roles de realm que comparten todos los sistemas (por ejemplo, admin, finanzas, docente).

Paso 3: registrar las aplicaciones como clientes

Por cada aplicación, en Clients → Create client:

Campo Laravel Next.js
Client ID app-erp portal
Client authentication Activado (cliente confidencial) Activado (el login corre en el servidor)
Flujo Standard flow Standard flow
Valid redirect URIs https://erp.midominio.cl/auth/callback https://portal.midominio.cl/api/auth/callback/keycloak
Valid post logout redirect URIs https://erp.midominio.cl/ https://portal.midominio.cl/
Web origins https://erp.midominio.cl https://portal.midominio.cl

Desactiva Direct access grants y Implicit flow si no los necesitas. Copia el client secret desde la pestaña Credentials.

Para que los roles lleguen en el token, revisa en Client scopes que el scope roles esté asignado; Keycloak incluye los roles de realm en el claim realm_access.roles del token de acceso.

Paso 4: Laravel con Socialite

composer require socialiteproviders/keycloak

config/services.php:

'keycloak' => [
    'client_id'     => env('KEYCLOAK_CLIENT_ID'),
    'client_secret' => env('KEYCLOAK_CLIENT_SECRET'),
    'redirect'      => env('KEYCLOAK_REDIRECT_URI'),
    'base_url'      => env('KEYCLOAK_BASE_URL'),   // https://auth.midominio.cl
    'realms'        => env('KEYCLOAK_REALM'),      // empresa
],

Registra el proveedor (en Laravel 11 o superior, en el boot de AppServiceProvider):

use Illuminate\Support\Facades\Event;
use SocialiteProviders\Manager\SocialiteWasCalled;

Event::listen(function (SocialiteWasCalled $event) {
    $event->extendSocialite('keycloak', \SocialiteProviders\Keycloak\Provider::class);
});

Rutas y controlador:

Route::get('/login', [SsoController::class, 'redirect'])->name('login');
Route::get('/auth/callback', [SsoController::class, 'callback']);
Route::post('/logout', [SsoController::class, 'logout'])->name('logout');
class SsoController extends Controller
{
    public function redirect()
    {
        return Socialite::driver('keycloak')->scopes(['openid', 'email', 'profile'])->redirect();
    }

    public function callback(Request $request)
    {
        $sso = Socialite::driver('keycloak')->user();

        $user = User::updateOrCreate(
            ['keycloak_sub' => $sso->getId()],   // identificador estable
            ['name' => $sso->getName(), 'email' => $sso->getEmail()],
        );

        Auth::login($user);
        $request->session()->regenerate();
        $request->session()->put('id_token', $sso->accessTokenResponseBody['id_token'] ?? null);

        return redirect()->intended('/');
    }

    public function logout(Request $request)
    {
        $idToken = $request->session()->get('id_token');

        Auth::logout();
        $request->session()->invalidate();
        $request->session()->regenerateToken();

        $url = config('services.keycloak.base_url')
            . '/realms/' . config('services.keycloak.realms')
            . '/protocol/openid-connect/logout?' . http_build_query([
                'id_token_hint' => $idToken,
                'post_logout_redirect_uri' => url('/'),
            ]);

        return redirect()->away($url);
    }
}

Dos decisiones importantes:

  • Vincula al usuario por sub, el identificador inmutable de Keycloak, no por el correo (que puede cambiar).
  • Mantén tu tabla users local. Keycloak resuelve quién es; tu aplicación sigue guardando sus propias relaciones y datos de negocio.

Paso 5: Next.js con Auth.js

npm install next-auth@beta

.env.local:

AUTH_SECRET=genera-uno-con-npx-auth-secret
AUTH_KEYCLOAK_ID=portal
AUTH_KEYCLOAK_SECRET=el-client-secret
AUTH_KEYCLOAK_ISSUER=https://auth.midominio.cl/realms/empresa

auth.ts:

import NextAuth from "next-auth";
import Keycloak from "next-auth/providers/keycloak";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [Keycloak],
  callbacks: {
    async jwt({ token, account }) {
      if (account?.access_token) {
        const payload = JSON.parse(
          Buffer.from(account.access_token.split(".")[1], "base64url").toString(),
        );
        token.roles = payload.realm_access?.roles ?? [];
        token.idToken = account.id_token;
      }
      return token;
    },
    async session({ session, token }) {
      session.user.roles = token.roles as string[];
      return session;
    },
  },
});

app/api/auth/[...nextauth]/route.ts:

import { handlers } from "@/auth";
export const { GET, POST } = handlers;

Proteger una página según rol:

import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function Finanzas() {
  const session = await auth();
  if (!session) redirect("/api/auth/signin");
  if (!session.user.roles?.includes("finanzas")) return <p>Sin acceso</p>;
  return <h1>Panel de finanzas</h1>;
}

Aquí el token ya fue validado por Auth.js durante el login, por eso leer el payload es seguro en ese punto. Si una API recibe tokens directamente desde el navegador, debe validar la firma contra el JWKS de Keycloak (/realms/empresa/protocol/openid-connect/certs), por ejemplo con la librería jose.

Cierre de sesión global

El SSO tiene una contraparte que se olvida: si el usuario cierra sesión en una aplicación, ¿qué pasa con las demás?

  • Logout iniciado en la aplicación (RP-initiated): redirigir al endpoint de logout de Keycloak con id_token_hint, como en el controlador de Laravel. Eso cierra la sesión en Keycloak.
  • Back-channel logout: en cada cliente configuras una Backchannel logout URL. Cuando la sesión de Keycloak termina, este llama a esa URL de cada aplicación con un logout token firmado, y la aplicación invalida la sesión local de ese usuario.

Sin back-channel logout, las demás aplicaciones mantienen su sesión local hasta que expire. Para sistemas internos con información sensible, impleméntalo.

Migrar usuarios que ya existen

Rara vez se parte de cero. Opciones:

  • Importación masiva con la API de administración de Keycloak, enviando un correo de "define tu contraseña" en el primer ingreso.
  • Federación de usuarios (LDAP o Active Directory) si ya existe un directorio.
  • Vinculación por correo en el primer login: si llega un sub nuevo con un correo que existe en la tabla local y está verificado en Keycloak, se asocia y desde ahí se usa el sub.

Errores comunes

  • "Invalid redirect_uri": la URL debe coincidir exactamente con la registrada, incluyendo protocolo, puerto y barra final.
  • "Invalid issuer" o tokens rechazados: KC_HOSTNAME no coincide con la URL pública o el proxy no envía X-Forwarded-Proto. Revisa /.well-known/openid-configuration y compara el campo issuer.
  • Bucle de redirecciones: cookies bloqueadas por SameSite o la aplicación detrás de un proxy sin confiar en los encabezados (en Laravel, configura los trusted proxies).
  • Tokens gigantes: con muchos roles y grupos, el token supera el límite de cookies o encabezados. Incluye solo los claims necesarios con client scopes dedicados.
  • Diferencias de reloj: si el reloj de un servidor está desfasado, los tokens parecen expirados o "del futuro". Usa NTP en todos.

Conclusión

Keycloak resuelve en una sola pieza lo que cada sistema intentaba resolver por su cuenta: login, contraseñas, 2FA, recuperación y roles. Con OIDC, conectar una aplicación nueva es configurar un cliente y unas cuantas líneas de código, y desactivar a una persona pasa a ser un solo clic. El costo es operar un componente crítico más: trátalo como tal, con alta disponibilidad, respaldos de su base de datos y monitoreo, porque si Keycloak se cae, nadie entra a nada.

compartir:linkedinxblueskywhatsappreddithacker newsfacebookcorreo

Comentarios

Aún no hay comentarios. ¿Quieres dejar el primero?

Deja un comentario

Los comentarios se revisan antes de publicarse. No pido correo ni guardo tu IP.

$ ls ~/blog | grep relacionados

Sigue leyendo

  • · postgresql

    Respaldos de PostgreSQL en S3: regla 3-2-1, IAM mínima y restauraciones probadas

    Cómo respaldar PostgreSQL en Amazon S3 (o un servicio compatible) de forma segura: pg_dump en streaming, un usuario IAM que solo puede subir, ciclo de vida para la retención, protección contra borrados y un script que prueba la restauración de forma automática.

  • · sii

    Cómo autenticarse en el SII con certificado digital desde Node.js: semilla, token y mTLS

    Guía práctica para autenticarse en los servicios del SII de Chile con un certificado digital desde Node.js: cómo leer el .pfx, firmar la semilla con XMLDSig, obtener el token, usar TLS mutuo y resolver los errores más comunes.

  • · self-hosting

    Tu propio Vercel en un VPS: despliegues con Coolify y Dokploy

    Cómo montar una plataforma de despliegue propia en un VPS con Coolify o Dokploy: push a Git y despliegue automático, SSL con Let's Encrypt, bases de datos y previews, más una comparación honesta con Vercel y sus límites reales.