@dacyti-gsi/auth
v0.5.0
Published
Cliente del SSO de la división: OIDC (PKCE), sesión y permisos para Next.js/React
Maintainers
Readme
@dacyti-gsi/auth
Cliente del Portal SSO. Autenticación OIDC y permisos con la mínima ceremonia.
Es el cliente de servidor. Mantiene el
client_secrety guarda la sesión en una cookieHttpOnly, así que necesita un backend: Next.js, un BFF, un servicio Node. Un frontend en el navegador no debe usarlo —no puede guardar un secreto ni una cookie—: se registra en el portal como cliente público (spaonative, solo PKCE, sin secreto) y usa una librería OIDC estándar contrahttps://sso.<tu-dominio>/api/auth.
Instalar
pnpm add @dacyti-gsi/authIntegrar una aplicación Next.js
1. Declara el cliente
// lib/sso.ts
import { defineNextSSO } from "@dacyti-gsi/auth/next";
export const sso = defineNextSSO({
// Convierte una errata en un error de compilación
permissions: ["documentos:crear", "documentos:anular"],
});Nada más: issuer, clientId, clientSecret y secret salen de
SSO_ISSUER, SSO_CLIENT_ID, SSO_CLIENT_SECRET y SSO_SECRET. Si falta
alguna, revienta al arrancar diciendo cuál.
2. Monta las rutas
// app/api/sso/[...sso]/route.ts
import { sso } from "@/lib/sso";
export const { GET, POST } = sso.handlers();
export const runtime = "nodejs";3. Úsalo
// app/panel/page.tsx
import { sso } from "@/lib/sso";
export default async function PanelPage() {
const session = await sso.protectPage();
return <p>Hola, {session.user.name}</p>;
}Eso es todo. No hay que escribir PKCE, ni manejar el state, ni canjear
códigos, ni saber que los claims van bajo un espacio de nombres.
Variables de entorno
SSO_ISSUER=https://sso.ejemplo.com/api/auth
SSO_CLIENT_ID=…
SSO_CLIENT_SECRET=…
# Cifra la cookie de sesión. Mínimo 32 caracteres.
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
SSO_SECRET=…Al registrar la aplicación en el portal indica esta URI de redirección:
https://tuapp.com/api/sso/callbackY, si quieres que cerrar sesión devuelva al usuario aquí, añade también una URI de cierre de sesión:
https://tuapp.com/El portal solo redirige a URIs registradas, así que sin ella la sesión se cierra
igual pero el navegador se queda en el portal. Si la registras, indícala también
en postLogoutRedirectUri.
Declarar permisos y roles desde el código (sso.sync())
El catálogo de la app vive en su código y se declara solo en el portal, sin copiarlo a mano a la consola:
// lib/sso.ts
export const sso = defineNextSSO({
permissions: ["pedidos:ver", "pedidos:anular"] as const,
roles: {
gerente: ["pedidos:ver", "pedidos:anular"],
lector: ["pedidos:ver"],
},
// Lo que la consola muestra al decidir a quién dar cada cosa.
descriptions: {
permissions: { "pedidos:anular": "Anular pedidos ya confirmados" },
roles: { gerente: "Gestiona los pedidos de su sucursal" },
},
});// instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const { sso } = await import("@/lib/sso");
await sso.sync().catch((error) => console.warn("[sso] sin sincronizar:", error));
}- Los permisos de cada rol salen del tipo de
permissions, y las claves dedescriptionstambién: una errata no compila. - Es idempotente y solo toca el catálogo de este
clientId(autentica con las mismas credenciales del cliente,client_secret_basic). Se puede llamar en cada arranque. - Declara el catálogo; no asigna roles a cuentas, eso se decide en la consola.
- No borra: un permiso o rol que quitas del código sigue en la consola hasta que alguien lo elimina allí.
- Todo o nada: si un rol pide un permiso que no está en el catálogo, el portal rechaza
el manifiesto entero y
sync()lanza con el motivo. - Un rol que incluya
sso:asignar-rolespermite a su titular asignar roles de esta app desde su cuenta, sin ser administrador del portal.
Renovar el acceso (proxy.ts)
El acceso dura 15 minutos. Los Server Components no pueden escribir cookies, así que sin ayuda cada página renderizada después vuelve a canjear tokens con el portal. Monta el proxy una vez por app y la cookie renovada viaja en la respuesta:
// src/proxy.ts
import { sso } from "@/lib/sso";
export const proxy = sso.proxy;
export const config = {
matcher: ["/((?!_next/|api/sso/|favicon.ico|.*\..*).*)"],
};Si el portal no responde, no hace nada y la página decide con lo que tenga.
Robustez
- Toda llamada al portal tiene tope de 10 s: un portal colgado no congela la app.
- Las rutas
/api/sso/*responden 503/502 genéricos (el detalle va al log del servidor). - Renovaciones simultáneas del mismo refresh token se unifican en una sola llamada.
- Cada intento de ingreso tiene su cookie (
sso_attempt_<state>): varias pestañas no se pisan. - Si la sesión no cabe en una cookie (~4 KB) falla con un mensaje claro en vez de un bucle de login.
protectPage({ access: true })deja fuera a quien se autenticó pero no tiene ningún permiso de la app.
Proteger
// Solo exige sesión
const session = await sso.protectPage();
// Declarativo: permiso + segundo factor + permisos vivos
const session = await sso.protectPage({
permission: "documentos:anular",
requireMfa: true,
revalidate: true,
});Sin sesión, protectPage() redirige a autenticarse. Con sesión pero sin el
permiso, lanza SSOForbiddenError. Sin segundo factor cuando se exige,
lanza SSOMfaRequiredError. Son tres situaciones distintas.
// app/panel/error.tsx o un try/catch
import { SSOForbiddenError } from "@dacyti-gsi/auth/next";
if (error instanceof SSOForbiddenError) {
// responde 403, no 500
}Server Actions declarativas
// app/panel/actions.ts
export const anularPedido = sso.action("documentos:anular", async (session, id: string) => {
// sesión ya autorizada con permisos vivos (revalidate por defecto)
});action() revalida por defecto: las escrituras consultan al portal en el
momento. Solo lectura barata usa { revalidate: false }.
Sin sesión, sin permiso o sin segundo factor, lanza por defecto —lo correcto
para páginas y rutas. Para formularios con useActionState, onDenied
convierte la denegación en valor. Un fallo del portal nunca se convierte:
una caída no es una denegación.
export const anular = sso.action("documentos:anular", run, {
onDenied: (error) => ({ ok: false as const, message: "Sin permiso." }),
});Componentes RSC
Salen del cliente, ya atados a él y con el catálogo de permisos tipado:
// lib/sso.ts
export const sso = defineNextSSO({ permissions: ["documentos:ver", "documentos:anular"] });
export const { Can, SignedIn, SignedOut, SignInLink, SignOutLink } = sso.components;<SignedOut>
<SignInLink returnTo="/panel">Entrar</SignInLink>
</SignedOut>
<SignedIn>
<SignOutLink>Salir</SignOutLink>
</SignedIn>
<Can permission="documentos:anular" fallback={<p>Sin acceso.</p>}>
<BotonAnular />
</Can>
<Can permission={["documentos:ver", "documentos:anular"]} mode="all" requireMfa>
<Auditoria />
</Can>Can(aliasProtect): niega por defecto —sin permiso pintanfallbacko nada. Nunca redirigen ni lanzan. Para páginas completas usaprotectPage(), que redirige a entrar.SignedIn/SignedOut: según haya sesión o no.SignInLink/SignOutLink: enlaces de entrada (con vuelta) y salida (local y portal). Son Server Components sin JS: solo un<a>bien armado.
Las versiones sueltas de @dacyti-gsi/auth/react (<Can sso={sso} …>) siguen
existiendo, pero lo normal es usar las atadas.
Configuración fail-fast
El cliente valida al crearse: issuer https (http solo localhost), secret de
32+, catálogo sin vacíos ni duplicados, basePath con / inicial. Un
error de configuración revienta al arrancar, no en el primer login.
La cookie es __Host-sso_session por defecto (Secure, HttpOnly,
SameSite=Lax, Path=/). Rompe sesiones previas una vez: salida y entrada.
Leer sin exigir
const session = await sso.session(); // SSOSession | null
if (session?.can("documentos:anular")) {
// …
}Que un cambio de rol surta efecto ya
session() lee la cookie: es un descifrado local, sin red, y por eso los
permisos son la foto del momento en que se autorizó la aplicación. Si un
administrador retira un rol, esa foto sigue diciendo lo mismo hasta que
caduque.
Para las operaciones donde eso no vale, pide permisos vivos:
// Una ida y vuelta al portal. Renueva el access token si caducó y vuelve a
// resolver los permisos.
const session = await sso.protectPage({ permission: "documentos:anular", revalidate: true });revalidate cuesta una llamada, así que no es el valor por defecto. Ponlo en
las pantallas y acciones que dependen de un permiso concreto —anular, pagar,
borrar— y deja el resto en el camino rápido. action() ya revalida por
defecto.
Además, si el portal responde que la credencial ya no vale —token revocado,
cliente desactivado, cuenta bloqueada— la sesión se da por terminada y
protectPage() vuelve a mandar a autenticarse. Un fallo de red o un 502 del
portal, en cambio, cae a la foto local: no son motivo para echar a nadie.
Lo que revalidate no hace: cerrar la sesión en el portal no apaga la
sesión local de tu aplicación. Son sesiones distintas y no hay cierre de sesión
en segundo plano; la copia local vive hasta sessionMaxAge. Si eso importa,
revalida en las operaciones sensibles o baja sessionMaxAge.
Renovar el acceso
El access_token dura quince minutos. Pasados, cualquier llamada a una API con
session.accessToken responde 401 aunque la cookie siga viva.
if (session.isExpired()) {
// Renueva desde una Server Action declarada, o navega a la ruta del SDK:
const renovar = sso.action({}, async () => {});
// o un enlace a sso.refreshPath("/panel")
}sso.action()revalida y persiste la cookie nueva. Es el camino para Server Actions y mutaciones.refreshPath(returnTo)apunta a la ruta del SDK que renueva, guarda la cookie y devuelve al usuario. Sirve desde cualquier sitio, incluido un enlace.resolveSession(cookie)es la versión de bajo nivel, para otros frameworks: devuelve{ session, cookie }y decide tú dónde guardarla.
Todo esto solo funciona si la autorización pidió el scope offline_access, que
el SDK incluye por defecto. La aplicación tiene que tenerlo permitido en el
portal: si no, el canje del código responde invalid_scope.
La sesión
session.user.id // identificador estable — úsalo como clave
session.user.email
session.user.emailVerified
session.user.name
session.roles // ["gerente"]
session.permissions // ["documentos:anular", "documentos:crear"]
session.amr // ["pwd", "otp", "mfa"]
session.can("documentos:anular")
session.canAll("documentos:crear", "documentos:anular")
session.canAny("documentos:crear", "informes:ver")
session.requires("documentos:anular") // lanza SSOForbiddenError
session.usedSecondFactor() // true si amr incluye "mfa"
session.isExpired() // true si el access token ya caducóPermisos tipados
El portal define los permisos y tu aplicación les da significado al exigirlos. Esta lista es el espejo para TypeScript —declara primero en la consola del portal— y con ella los nombres se comprueban al compilar:
export const sso = defineNextSSO({
// …
permissions: ["documentos:crear", "documentos:anular"],
});
session.can("documentos:crear"); // ✔
session.can("documentos:crea"); // ✘ error de compilaciónSin esa lista todo sigue funcionando, pero una errata devuelve false en
silencio en producción en lugar de fallar al compilar. El SDK nunca valida la
forma de un permiso: el portal acepta cualquier cadena opaca y lo que no está
declarado ahí, simplemente nunca llega en la sesión.
Exigir segundo factor
const session = await sso.protectPage({ permission: "documentos:anular", requireMfa: true });Enlaces
<a href={sso.signInPath("/panel")}>Entrar</a>
<a href={sso.signOutPath()}>Salir</a>signOutPath() cierra la sesión local y la del portal. Cerrar solo la local
dejaría a la persona dentro del portal, y el siguiente signIn la devolvería sin
pedir nada.
Cerrar la sesión del portal exige que la aplicación esté registrada con
enable_end_session y con al menos una URI de cierre de sesión. Ambas cosas las
pone el portal al registrar la aplicación desde la consola.
Fuera de Next.js
El núcleo no depende de ningún framework:
import { defineSSO } from "@dacyti-gsi/auth";
const sso = defineSSO({ issuer, clientId, clientSecret, secret });
// En tu router, para cualquier ruta bajo /api/sso/
const response = await sso.handleRequest(request); // Response | null
// Con el valor de la cookie
const session = await sso.readSession(cookieValue);handleRequest devuelve null si la ruta no le compete, para que puedas
encadenarlo con el resto de tu enrutado. Las rutas que atiende son sign-in,
callback, sign-out y refresh.
resolveSession es la que habla con el portal: renueva el acceso caducado,
vuelve a resolver los permisos si le pasas revalidate y te devuelve la cookie
nueva para que la guardes donde guardes la tuya.
const resolved = await sso.resolveSession(cookie, { revalidate: true });
// resolved === null → la sesión terminó
// resolved.cookie → guárdala; `null` si nada cambióOpciones
| Opción | Por defecto | |
|---|---|---|
| issuer | — | Obligatoria |
| clientId | — | Obligatoria |
| clientSecret | — | Obligatoria |
| secret | — | Obligatoria. Mínimo 32 caracteres |
| permissions | — | Espejo del catálogo del portal, solo para tipado |
| basePath | /api/sso | Dónde montas los handlers |
| appUrl | se deduce | Indícala si el proxy no reescribe X-Forwarded-Host |
| postLogoutRedirectUri | — | A dónde vuelve el navegador tras cerrar sesión. Debe estar registrada en el portal |
| scopes | openid profile email offline_access | |
| cookieName | __Host-sso_session | |
| sessionMaxAge | 7 días | Sesión local, en segundos |
Qué hace por dentro
- PKCE
S256obligatorio. Verifier de 48 bytes, challenge SHA-256. statecomprobado en tiempo constante. Sin esa comprobación, un tercero puede forzar a alguien a completar un flujo que empezó él, y acabar con la sesión del atacante iniciada en el navegador de la víctima.- Cookie de sesión cifrada (AES-256-GCM), no solo firmada: contiene el access token, y una cookie firmada es legible por cualquiera que la tenga delante.
- El destino tras entrar solo puede ser una ruta de tu aplicación. Se rechazan las URLs absolutas, las relativas al protocolo y las barras invertidas.
- Los claims se localizan por sufijo, así que no hay que configurar el dominio del portal ni actualizarlo si se mueve.
- La cookie temporal del intento se borra al usarla.
- El
id_tokense guarda porque el cierre de sesión del portal lo exige. - El access token se renueva solo cuando se pide con
revalidate.
Limitación conocida
Los permisos llegan en /oauth2/userinfo, no en el token. El SDK los pide en el
callback y los guarda en la sesión local.
Consecuencia: por defecto, un cambio de rol no se ve hasta que la sesión
local se renueve —hasta sessionMaxAge—. Para las operaciones que dependen de un
permiso concreto, usa protect(permiso, { revalidate: true }), que consulta al
portal en ese momento.
Licencia
Apache-2.0. El código fuente del cliente vive en dacyti-gsi/auth y el del portal en dacyti-gsi/sso.
