
Security News
arXiv Is Rate Limiting Authors Following a Flood of AI Slop Submissions
arXiv now limits authors to two submissions a month as AI slop overwhelms moderators, delays good papers, and sparks debate over applying the limit to everyone.
@novatic/auth
Advanced tools
Self-contained OIDC auth for Next.js (App Router) with encrypted sessions, automatic token refresh and a mountable auth BFF.
Autenticación OIDC autocontenida para apps Next.js (App Router). Gestiona
todo el proceso: flujo Authorization Code, intercambio del code en el
callback, creación de la sesión cifrada, retorno del token con refresh
automático, y el logout (RP-initiated) — todo recibiendo por parámetros lo
que necesita. Tu app solo decide qué hacer con la información del token.
No lee process.env: tú pasas issuer, client, secret, redirect_uri y destinos.
Server-only. Importa
next/serverynext/headers; úsalo solo en route handlers, server actions yproxy.ts. Nunca desde componentes cliente.
^16 (peer dependency).>= 20.pnpm add @novatic/auth
npm i @novatic/auth
No necesitas transpilePackages: el paquete llega compilado (dist/ + tipos).
Crea un único módulo de instancia en tu app, p. ej. src/libs/auth.ts:
import { createAuth, normalizeLoginMethod, resolveCallbackUrl } from '@novatic/auth'
export const auth = createAuth({
// ── Identidad de tu SP ante el Identity Server ──
issuerBaseUrl: process.env.NEXT_PUBLIC_IS_BASE_URL!, // https://is.ejemplo.com
clientId: process.env.OIDC_CLIENT_ID!,
// Helpers puros: la app lee env, la lib normaliza/valida (ver §3bis).
loginMethod: normalizeLoginMethod(process.env.OIDC_TOKEN_ENDPOINT_AUTH_METHOD, {
envName: 'OIDC_TOKEN_ENDPOINT_AUTH_METHOD', // el error cita la variable
}),
clientSecret: process.env.OIDC_CLIENT_SECRET, // solo con 'basic' | 'post'
// OIDC_REDIRECT_URI admite ruta (dev) o URL absoluta (K8s) — sin concatenar a mano
callbackUrl: resolveCallbackUrl({
redirectUri: process.env.OIDC_REDIRECT_URI,
appUrl: process.env.NEXT_PUBLIC_APP_URL,
}),
// ── Sesión ──
scopes: (process.env.OIDC_SCOPES ?? 'openid').split(' '),
sessionSecret: process.env.SESSION_SECRET!,
cookiePrefix: process.env.COOKIE_PREFIX ?? 'myapp', // evita colisión entre apps
// ── Destinos del flujo (los decides tú) ──
postLoginRedirect: process.env.POST_LOGIN_URL ?? '/dashboard',
postLogoutFallback: process.env.POST_LOGOUT_URL ?? '/',
})
Monta el BFF interno en un solo archivo src/app/api/auth/[...action]/route.ts:
import { auth } from '@/libs/auth'
export const runtime = 'nodejs'
const bff = auth.bff()
export const GET = bff.GET
export const POST = bff.POST
Y las rutas de login/registro (páginas → route handlers):
// src/app/login/route.ts
import { auth } from '@/libs/auth'
export const runtime = 'nodejs'
export const GET = auth.handlers.login
// src/app/register/route.ts
import { auth } from '@/libs/auth'
export const runtime = 'nodejs'
export const GET = auth.handlers.register
Hecho. Tienes login, callback, refresh, logout y me funcionando, con
el redirect_uri registrado en tu SP apuntando a /api/auth/callback.
createAuth(config)| Parámetro | Tipo | Default | Cuándo tocarlo |
|---|---|---|---|
issuerBaseUrl | string | — (obligatorio) | Base del IS. De aquí se derivan authorize, discovery y SCIM2. |
clientId | string | — (obligatorio) | Tu SP ante el IS. |
loginMethod | 'pkce' | 'basic' | 'post' | — (obligatorio) | Cómo autentica la app en el token endpoint: 'pkce' = cliente público + PKCE S256 (sin secret); 'basic'/'post' = confidencial, credenciales en header Authorization o en el body. Sin default y sin fallback: el IS rechaza más de un método por petición (RFC 6749 §2.3). |
clientSecret | string | undefined | — | Server-only. Requerido con 'basic'/'post'; ignorado con 'pkce'. |
callbackUrl | string | — (obligatorio) | redirect_uri registrado en el IS (absoluto). |
scopes | string[] | ['openid'] | P. ej. ['openid','email','profile']. |
sessionSecret | string | — (obligatorio) | SESSION_SECRET (hex 32+ bytes). Fail-fast si falta. |
cookiePrefix | string | 'oidc' | Prefijo de TODAS las cookies de auth. Úsalo distinto por app si comparten dominio. |
sessionCookieName | string | `${prefix}-session` | Nombre explícito de la cookie de sesión. |
sessionTtlSeconds | number | 86400 | TTL de la sesión cifrada. |
postLoginRedirect | string | '/select-organization' | A dónde va el usuario tras login exitoso. |
postLogoutFallback | string | '/' | Destino si no hay cookie de coordinación de logout. |
loginPath | string | '/login' | Ruta de login (para el guard y reintentos). |
routes | Partial<IsRoutes> | derivado de issuerBaseUrl | Sobreescribe paths no estándar del IS (ver §6). |
mapClaimsToUser | (claims) => DefaultUser | sub/email/nombre | Mapea claims del id_token al shape de usuario de tu app. |
Fail-fast: si falta issuerBaseUrl, clientId, callbackUrl o sessionSecret,
createAuth() lanza con mensaje claro en boot (no silencioso). Igual con
loginMethod: ausente o fuera de pkce | basic | post → throw; y 'basic'/'post'
sin clientSecret → throw.
La lib nunca lee process.env (ni en los helpers): tu app lee la env y pasa
el valor crudo; los helpers normalizan, validan y resuelven. Usarlos en todas
las apps garantiza idéntica normalización, fail-fast y contrato ruta/URL.
normalizeLoginMethod(raw, { envName? })Normaliza (trim + lowercase) y valida el valor de
OIDC_TOKEN_ENDPOINT_AUTH_METHOD. Lanza en boot citando la variable si
envName se pasa (fail-fast sin default ni fallback).
import { createAuth, normalizeLoginMethod } from '@novatic/auth'
createAuth({
// ...
loginMethod: normalizeLoginMethod(process.env.OIDC_TOKEN_ENDPOINT_AUTH_METHOD, {
envName: 'OIDC_TOKEN_ENDPOINT_AUTH_METHOD',
}),
})
resolveCallbackUrl({ redirectUri, appUrl })Resuelve el redirect_uri registrado contra el IS. Soportadas dos formas del
valor de OIDC_REDIRECT_URI:
/api/auth/callback, dev local) → se une a appUrl sin dobles /;createAuth({
// ...
callbackUrl: resolveCallbackUrl({
redirectUri: process.env.OIDC_REDIRECT_URI,
appUrl: process.env.NEXT_PUBLIC_APP_URL,
}),
})
Los defaults de NEGOCIO (cookiePrefix, postLoginRedirect, postLogoutFallback)
no son de la lib: cada app los pasa en createAuth desde sus propias envs.
auth.bff())Todos viven bajo /api/auth/[...action]:
| Endpoint | Método | Qué hace |
|---|---|---|
/api/auth/callback | GET | Valida state (anti-CSRF) → intercambia code → tokens → sesión cifrada → 302 a postLoginRedirect. Maneja: reintento non-silent tras error=login_required (silente), logout RP-initiated (-logoutcb), fallback post-logout, y clasifica fallos de red (→ /auth/error) vs protocolo (→ login). |
/api/auth/login | — | Ver §2 (route handler aparte en /login). |
/api/auth/logout | POST | Limpia sesión local + devuelve endSessionUrl del IS en JSON (la app navega con window.location). |
/api/auth/refresh | POST | Renueva el access token con el refresh token; invalida sesión si falla. |
/api/auth/me | GET | Usuario ligero de la sesión + organizations + expiración. Sustituible (ver §5). |
me por defecto vs enriquecidoEl me por defecto solo devuelve la sesión ligera. Si tu app necesita perfil completo
(SCIM2 /Me, etc.), pásale tu propio handler:
const bff = auth.bff({ me: myMeHandler })
El handler recibe (req) => Response y puede usar auth.getSession() y
auth.getValidAccessToken(). Esto mantiene la lógica de negocio en tu app, montada
a través de la librería.
La librería te entrega el token válido; tú decides qué hacer con él:
const token = await auth.getValidAccessToken()
// → access token válido; si expira en <5min lo REFRESCA solo y persiste en la sesión
// → null si no hay sesión o el refresh falló (sesión invalidada)
Patrón típico (inyectar Bearer en el cliente BFF → backend de negocio):
backendClient.interceptors.request.use(async (config) => {
const token = await auth.getValidAccessToken()
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
Otros miembros de la instancia:
| Miembro | Para qué |
|---|---|
auth.getSession() | Sesión server-side (usa next/headers). Solo en route handlers/server actions. |
auth.getValidAccessToken() | Token válido con auto-refresh. |
auth.hasValidSession() | true si hay tokens válidos. |
auth.beginOidcFlow(req, { silent }) | Inicia el flujo manualmente (state/nonce + 302). |
auth.buildEndSessionUrl(state?) | URL de logout RP-initiated del IS. |
auth.unsealFromRequest(req) | Lee la cookie sin next/headers (para proxy). |
auth.createRouteGuard(opts) | Guard de rutas para proxy.ts. |
mapClaimsToUser)createAuth({
// ...
mapClaimsToUser: (claims) => ({
sub: String(claims.sub),
email: String(claims.email),
given_name: String(claims.given_name),
family_name: String(claims.family_name),
// campos extra que tu app necesite...
}),
})
El tipo de sesión es genérico: auth.getSession<TuUser>().
Dos apps bajo el mismo dominio compartirían cookies si usan el mismo prefijo.
Pon un cookiePrefix distinto por app ('portal', 'tienda'). La librería deriva
todas las cookies de ese prefijo.
Cada app se despliega a su dominio y registra su callbackUrl como redirect_uri en
el SP del IS. No hay nada compartido en runtime — el artifact de cada app es independiente.
Por defecto se derivan de issuerBaseUrl (con sabor WSO2, sobreescribible):
authorize: ${issuerBaseUrl}/oauth2/authorizediscovery: ${issuerBaseUrl}/oauth2/token/.well-known/openid-configurationscimMe: ${issuerBaseUrl}/scim2/MeSi tu IS se desvía, sobreescribe solo lo necesario:
createAuth({
// ...
routes: {
authorize: 'https://is.ejemplo.com/custom/authorize',
// discovery y scimMe usan el default
},
})
end_session_endpoint y token se resuelven automáticamente vía discovery.
proxy.ts)Next.js 16 sustituye middleware.ts por proxy.ts. Usa el guard de la lib:
// src/proxy.ts
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/libs/auth'
const guard = auth.createRouteGuard({
protectedRoutes: ['/dashboard', '/account'],
requiredCookies: [
{ cookie: 'myapp-org', redirectTo: '/select-organization', routes: ['/dashboard'] },
],
authenticatedHome: '/dashboard',
})
export default async function proxy(request: NextRequest) {
const res = await guard(request)
if (res) return res // redirección (no autenticado / falta org)
return NextResponse.next()
}
Opciones: protectedRoutes, requiredCookies (opcional routes para limitar a rutas
concretas), authenticatedHome, loginPath, refreshPath. El guard dispara refresh
proactivo en background cuando el token está por vencer.
loginMethod:
'pkce' = cliente público (sin secret) con PKCE S256; 'basic'/'post' =
confidencial con client_secret server-only. PKCE y secret son excluyentes
en la mayoría de proveedores estrictos (RFC 6749 §2.3) — por eso no hay
default ni fallback: se declara SIEMPRE.state/nonce efímeros anti-CSRF; validación de firma/JWKS/iss/aud/exp por openid-client.-logoutcb.| Síntoma | Causa probable | Fix |
|---|---|---|
createAuth() faltan parámetros... | No pasaste un campo obligatorio desde la app | Pásalos desde envs de tu app; la lib no lee process.env. |
| Sesiones de dos apps se pisan | Mismo cookiePrefix bajo un dominio | Usa cookiePrefix distinto por app. |
| Login bucle infinito | IS inalcanzable (red/DNS/TLS) | La lib redirige a /auth/error?reason=idp-unreachable (no a login). Revisa conectividad al IS. |
redirect_uri does not match | callbackUrl ≠ URI registrada en el SP | Registra https://tu-app/api/auth/callback en el IS. |
El callback falla con invalid_request "MUST NOT use more than one authentication method" | loginMethod desalineada con cómo está registrado el SP (el IS ve credenciales por dos vías) | Declara el método real del SP: 'pkce' si es cliente público con PKCE; 'basic'/'post' si es confidencial. Solo uno. |
| Token no se refresca | Sin refreshToken o revocado | getValidAccessToken() devuelve null; tu app debe pedir re-login. |
src/
├── index.ts # createAuth() + re-exports de tipos
├── types.ts # AuthConfig, Session, GuardOptions, Auth
├── core/
│ ├── oidc.ts # cliente OIDC POR INSTANCIA (closure, no singleton global)
│ ├── login-method.ts # vocabulario 'pkce'|'basic'|'post' + traducción RFC 7591
│ │ # + normalizeLoginMethod (helper puro para apps)
│ ├── callback-url.ts # resolveCallbackUrl (helper puro: ruta o URL absoluta)
│ ├── pkce.ts # par verifier/challenge S256 (RFC 7636, node:crypto)
│ └── errors.ts # clasificación red-vs-protocolo
├── session.ts # iron-session: options + getServerSession + unsealFromRequest
├── token.ts # getValidAccessToken / hasValidSession (auto-refresh)
├── bff/
│ ├── handlers.ts # login/callback/logout/refresh/me
│ ├── router.ts # dispatch /api/auth/[...action]
│ └── post-logout.ts
└── guards.ts # createRouteGuard
La app no escribe ni una línea de OIDC: solo instala, instancia, monta y registra:
pnpm add @novatic/auth (requiere next ^16 como peer).src/libs/auth.ts (ver §2): createAuth({...})
con normalizeLoginMethod + resolveCallbackUrl.src/app/api/auth/[...action]/route.ts (auth.bff()).auth.handlers.login / .register).proxy.ts con auth.createRouteGuard({...}) e inyección de
token en el cliente BFF con auth.getValidAccessToken() (ver §4 y §7).redirect_uri https://<tu-dominio>/api/auth/callback
en el SP del Identity Server.MIT — ver LICENSE.
FAQs
Self-contained OIDC auth for Next.js (App Router) with encrypted sessions, automatic token refresh and a mountable auth BFF.
We found that @novatic/auth demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
arXiv now limits authors to two submissions a month as AI slop overwhelms moderators, delays good papers, and sparks debate over applying the limit to everyone.

Research
/Security News
A new GhostAction wave hits hundreds of GitHub repos, expanding CI/CD secret theft to cloud and AI credentials in source code and git history.

Research
/Security News
Tensorlake npm SDK version 0.5.144 was compromised in a ChainDrop / Shai-Hulud attack, delivering credential-stealing malware.