npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dacyti-gsi/auth

v0.5.0

Published

Cliente del SSO de la división: OIDC (PKCE), sesión y permisos para Next.js/React

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_secret y guarda la sesión en una cookie HttpOnly, 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 (spa o native, solo PKCE, sin secreto) y usa una librería OIDC estándar contra https://sso.<tu-dominio>/api/auth.

Instalar

pnpm add @dacyti-gsi/auth

Integrar 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/callback

Y, 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 de descriptions tambié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-roles permite 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 (alias Protect): niega por defecto —sin permiso pintan fallback o nada. Nunca redirigen ni lanzan. Para páginas completas usa protectPage(), 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ón

Sin 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 S256 obligatorio. Verifier de 48 bytes, challenge SHA-256.
  • state comprobado 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_token se 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.