@dacyti-gsi/auth 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/next.ts ADDED
@@ -0,0 +1,296 @@
1
+ import { cookies } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+ import { type NextRequest, NextResponse } from "next/server";
4
+
5
+ import { defineSSO, warnIfStaleSession, type SSOClient } from "./index";
6
+ import { bindComponents } from "./react";
7
+ import {
8
+ type AccessPolicy,
9
+ type AuthorizationError,
10
+ authorize,
11
+ isAuthorizationError,
12
+ } from "./policy";
13
+ import type { SSOClientOptions, SSOSession } from "./types";
14
+
15
+ /** Cuándo una pantalla quiere permisos vivos en lugar de la foto de la cookie. */
16
+ export type SessionOptions = {
17
+ /**
18
+ * Vuelve a preguntar al portal por la identidad y los permisos.
19
+ *
20
+ * Cuesta una ida y vuelta —y una renovación de token si el acceso caducó—, así
21
+ * que no se hace por defecto. A cambio, es lo que hace que revocar un rol se
22
+ * note de inmediato. Para operaciones que dependen de un permiso concreto, es
23
+ * la opción correcta aunque el camino rápido sea más barato.
24
+ */
25
+ revalidate?: boolean;
26
+ };
27
+
28
+ export type NextSSO<Permission extends string> = SSOClient<Permission> & {
29
+ /**
30
+ * Handlers para el route handler catch-all.
31
+ *
32
+ * ```ts
33
+ * // app/api/sso/[...sso]/route.ts
34
+ * export const { GET, POST } = sso.handlers();
35
+ * export const runtime = "nodejs";
36
+ * ```
37
+ */
38
+ handlers(): {
39
+ GET: (request: Request) => Promise<Response>;
40
+ POST: (request: Request) => Promise<Response>;
41
+ };
42
+
43
+ /** Sesión actual, o `null`. Nunca redirige ni lanza. */
44
+ session(options?: SessionOptions): Promise<SSOSession<Permission> | null>;
45
+
46
+ /**
47
+ * Guarda declarativo para páginas: exige la política.
48
+ *
49
+ * ```ts
50
+ * const session = await sso.protectPage({ permission: "documentos:anular", requireMfa: true });
51
+ * ```
52
+ *
53
+ * Sin sesión redirige a autenticarse (con vuelta a la página actual cuando se
54
+ * le pasa `returnTo`). Sin permiso o sin segundo factor, lanza
55
+ * `SSOForbiddenError` / `SSOMfaRequiredError` para responder 403.
56
+ */
57
+ protectPage(
58
+ policy?: AccessPolicy<Permission> & { returnTo?: string },
59
+ ): Promise<SSOSession<Permission>>;
60
+
61
+ /**
62
+ * Envuelve una Server Action o mutación con la política exigida.
63
+ *
64
+ * Revalida y persiste por defecto: las escrituras no usan la foto de la
65
+ * cookie sino permisos vivos del portal. Pasa `{ revalidate: false }` solo
66
+ * para lecturas baratas.
67
+ *
68
+ * ```ts
69
+ * export const anular = sso.action("documentos:anular", async (session, id: string) => { … });
70
+ * ```
71
+ *
72
+ * Sin sesión, sin permiso o sin segundo factor, lanza por defecto
73
+ * (`SSOAuthRequiredError`, `SSOForbiddenError`, `SSOMfaRequiredError`): lo
74
+ * correcto para páginas y rutas, donde el error lo atiende `error.tsx` o un
75
+ * 403. Para formularios con `useActionState`, pasa `onDenied` y la
76
+ * denegación se convierte en valor en lugar de reventar la acción.
77
+ *
78
+ * ```ts
79
+ * export const anular = sso.action("documentos:anular", run, {
80
+ * onDenied: () => ({ ok: false as const, message: "Sin permiso." }),
81
+ * });
82
+ * ```
83
+ */
84
+ action<Args extends unknown[], Out>(
85
+ policy: Permission | AccessPolicy<Permission>,
86
+ fn: (session: SSOSession<Permission>, ...args: Args) => Promise<Out>,
87
+ options?: {
88
+ onDenied?: (error: AuthorizationError) => Promise<Out> | Out;
89
+ },
90
+ ): (...args: Args) => Promise<Out>;
91
+
92
+ /**
93
+ * URL que renueva la sesión y vuelve.
94
+ *
95
+ * Se usa como destino de un enlace o una navegación. Al pasar por un route
96
+ * handler, la cookie renovada sí se persiste.
97
+ */
98
+ refreshPath(returnTo?: string): string;
99
+
100
+ /**
101
+ * Renueva el acceso caducado ANTES de renderizar y deja la cookie nueva en la respuesta.
102
+ *
103
+ * Los Server Components no pueden escribir cookies: sin esto, pasados los 15 minutos de acceso
104
+ * cada render vuelve a canjear el refresh token (dos llamadas al portal por página) y, si el
105
+ * portal rota los tokens, acaba fallando. Se monta una vez por app:
106
+ *
107
+ * ```ts
108
+ * // proxy.ts
109
+ * import { sso } from "@/lib/sso";
110
+ * export const proxy = sso.proxy;
111
+ * export const config = { matcher: ["/((?!_next/|api/sso/|favicon.ico).*)"] };
112
+ * ```
113
+ *
114
+ * Si el portal no responde no hace nada: la página decide con lo que tenga.
115
+ */
116
+ proxy(request: NextRequest): Promise<NextResponse>;
117
+
118
+ /**
119
+ * Componentes de React ya atados a este cliente y con los permisos tipados:
120
+ * `Can`, `Protect`, `SignedIn`, `SignedOut`, `SignInLink`, `SignOutLink`.
121
+ */
122
+ components: ReturnType<typeof bindComponents<Permission>>;
123
+ };
124
+
125
+ /**
126
+ * Declara el cliente del portal con las piezas de Next.js ya montadas.
127
+ *
128
+ * ```ts
129
+ * // lib/sso.ts
130
+ * export const sso = defineNextSSO({
131
+ * permissions: ["documentos:crear", "documentos:anular"],
132
+ * });
133
+ * // El resto sale del entorno: SSO_ISSUER, SSO_CLIENT_ID,
134
+ * // SSO_CLIENT_SECRET, SSO_SECRET, SSO_POST_LOGOUT_URI.
135
+ * ```
136
+ */
137
+ export function defineNextSSO<const Permission extends string = string>(
138
+ options: SSOClientOptions<Permission>,
139
+ ): NextSSO<Permission> {
140
+ const client = defineSSO<Permission>(options);
141
+
142
+ /**
143
+ * En producción las cookies van con `Secure`; en desarrollo por HTTP, no.
144
+ * `appUrl` es la fuente más fiable: el servidor de Next.js no ve el protocolo
145
+ * del navegador desde aquí.
146
+ */
147
+ const secure = (options.appUrl ?? "").startsWith("https://")
148
+ ? true
149
+ : process.env.NODE_ENV === "production";
150
+
151
+ async function handle(request: Request): Promise<Response> {
152
+ const response = await client.handleRequest(request);
153
+
154
+ return (
155
+ response ??
156
+ new Response("Ruta del cliente SSO desconocida.", { status: 404 })
157
+ );
158
+ }
159
+
160
+ async function cookieValue(): Promise<string | undefined> {
161
+ const store = await cookies();
162
+
163
+ return store.get(client.cookieName)?.value;
164
+ }
165
+
166
+ /**
167
+ * Resuelve la sesión: camino rápido local, o contra el portal con
168
+ * `revalidate`. Un fallo del portal no cierra la sesión de inmediato: cae a
169
+ * la foto local, salvo que el acceso llevara caducado más de
170
+ * `MAX_STALE_SECONDS` (ver `warnIfStaleSession`).
171
+ */
172
+ async function resolve(
173
+ revalidate: boolean,
174
+ ): Promise<SSOSession<Permission> | null> {
175
+ try {
176
+ const resolved = await client.resolveSession(await cookieValue(), {
177
+ revalidate,
178
+ });
179
+ // Donde se puede escribir (Server Actions, route handlers) se guarda la renovación;
180
+ // en un Server Component `cookies().set` lanza y la renueva `proxy()`.
181
+ await persist(resolved?.cookie ?? null).catch(() => {});
182
+ return resolved?.session ?? null;
183
+ } catch {
184
+ // El núcleo ya aplica la ventana de gracia al revalidar; aquí solo
185
+ // queda la foto de la cookie. Si también está caducada hace más de la
186
+ // ventana, no se sirve: una caída del portal no es barra libre.
187
+ const session = await client.readSession(await cookieValue());
188
+ if (session && warnIfStaleSession(session.expiresAt)) return null;
189
+ return session;
190
+ }
191
+ }
192
+
193
+ async function persist(cookie: string | null): Promise<void> {
194
+ if (!cookie) return;
195
+ const store = await cookies();
196
+ store.set(client.cookieName, cookie, {
197
+ path: "/",
198
+ httpOnly: true,
199
+ sameSite: "lax",
200
+ secure,
201
+ maxAge: client.sessionMaxAge,
202
+ });
203
+ }
204
+
205
+ const self: Omit<NextSSO<Permission>, "components"> = {
206
+ ...client,
207
+
208
+ handlers: () => ({ GET: handle, POST: handle }),
209
+
210
+ session: (sessionOptions = {}) =>
211
+ resolve(sessionOptions.revalidate ?? false),
212
+
213
+ async proxy(request) {
214
+ const current = request.cookies.get(client.cookieName)?.value;
215
+ if (!current) return NextResponse.next();
216
+
217
+ let resolved: Awaited<ReturnType<typeof client.resolveSession>>;
218
+ try {
219
+ resolved = await client.resolveSession(current);
220
+ } catch {
221
+ return NextResponse.next();
222
+ }
223
+ if (!resolved?.cookie) return NextResponse.next();
224
+
225
+ // La página de esta misma petición debe ver la cookie nueva, no la caducada.
226
+ request.cookies.set(client.cookieName, resolved.cookie);
227
+ const response = NextResponse.next({ request });
228
+ response.cookies.set(client.cookieName, resolved.cookie, {
229
+ path: "/",
230
+ httpOnly: true,
231
+ sameSite: "lax",
232
+ secure,
233
+ maxAge: client.sessionMaxAge,
234
+ });
235
+ return response;
236
+ },
237
+
238
+ refreshPath(returnTo) {
239
+ return returnTo
240
+ ? `${client.basePath}/refresh?to=${encodeURIComponent(returnTo)}`
241
+ : `${client.basePath}/refresh`;
242
+ },
243
+
244
+ async protectPage(policy = {}) {
245
+ const { returnTo, ...access } = policy;
246
+ const session = await resolve(access.revalidate ?? false);
247
+ if (!session) redirect(client.signInPath(returnTo));
248
+ return authorize(session, access);
249
+ },
250
+
251
+ action<Args extends unknown[], Out>(
252
+ this: void,
253
+ policy: Permission | AccessPolicy<Permission>,
254
+ fn: (session: SSOSession<Permission>, ...args: Args) => Promise<Out>,
255
+ options?: {
256
+ onDenied?: (error: AuthorizationError) => Promise<Out> | Out;
257
+ },
258
+ ): (...args: Args) => Promise<Out> {
259
+ const access: AccessPolicy<Permission> =
260
+ typeof policy === "string"
261
+ ? { permission: policy, revalidate: true }
262
+ : { revalidate: true, ...policy };
263
+ return (async (...args: Args) => {
264
+ const resolved = await client.resolveSession(await cookieValue(), {
265
+ revalidate: access.revalidate ?? true,
266
+ });
267
+ let session: SSOSession<Permission>;
268
+ try {
269
+ session = authorize(resolved?.session ?? null, access);
270
+ } catch (error: unknown) {
271
+ // Solo las denegaciones de la política se convierten en valor, y
272
+ // solo si se pidió: un fallo del portal sigue lanzando.
273
+ if (options?.onDenied && isAuthorizationError(error)) {
274
+ return options.onDenied(error);
275
+ }
276
+ throw error;
277
+ }
278
+ await persist(resolved?.cookie ?? null);
279
+ return fn(session, ...args);
280
+ }) as (...args: Args) => Promise<Out>;
281
+ },
282
+ };
283
+
284
+ return { ...self, components: bindComponents<Permission>(self) };
285
+ }
286
+
287
+ export { SSOForbiddenError } from "./errors";
288
+ export {
289
+ type AccessPolicy,
290
+ type AuthorizationError,
291
+ authorize,
292
+ isAuthorizationError,
293
+ SSOAuthRequiredError,
294
+ SSOMfaRequiredError,
295
+ } from "./policy";
296
+ export type { SSOSession, SSOUser } from "./types";
package/src/pkce.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+
3
+ function base64url(input: Buffer): string {
4
+ return input.toString("base64url");
5
+ }
6
+
7
+ export type AuthorizationAttempt = {
8
+ /** Se guarda en la cookie temporal y se envía al canjear el código. */
9
+ verifier: string;
10
+ /** Viaja en la URL de autorización. */
11
+ challenge: string;
12
+ /** Debe volver intacto; es la protección contra CSRF en el redirect. */
13
+ state: string;
14
+ };
15
+
16
+ /**
17
+ * Prepara un intento de autorización con PKCE.
18
+ *
19
+ * El verifier son 48 bytes aleatorios; el challenge es su SHA-256. El portal
20
+ * exige `S256` para todos los clientes, también los confidenciales: sin PKCE,
21
+ * un código interceptado en el redirect puede canjearse desde otro sitio.
22
+ */
23
+ export function createAuthorizationAttempt(): AuthorizationAttempt {
24
+ const verifier = base64url(randomBytes(48));
25
+
26
+ return {
27
+ verifier,
28
+ challenge: base64url(createHash("sha256").update(verifier).digest()),
29
+ state: base64url(randomBytes(16)),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Compara dos `state` sin filtrar información por el tiempo de comparación.
35
+ *
36
+ * Una comparación normal con `===` sale antes en el primer carácter distinto,
37
+ * lo que permitiría adivinar el valor byte a byte cronometrando.
38
+ */
39
+ export function statesMatch(expected: string, received: string): boolean {
40
+ if (expected.length !== received.length) return false;
41
+
42
+ let difference = 0;
43
+ for (let index = 0; index < expected.length; index += 1) {
44
+ difference |= expected.charCodeAt(index) ^ received.charCodeAt(index);
45
+ }
46
+
47
+ return difference === 0;
48
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { SSOError, SSOForbiddenError } from "./errors";
2
+ import type { SSOSession } from "./types";
3
+
4
+ /** La sesión terminó o nunca existió: hay que mandar a autenticarse. */
5
+ export class SSOAuthRequiredError extends SSOError {
6
+ constructor(message = "Se requiere sesión.") {
7
+ super(message);
8
+ }
9
+ }
10
+
11
+ /** Hay sesión pero sin segundo factor, y la política lo exige. */
12
+ export class SSOMfaRequiredError extends SSOError {
13
+ constructor(message = "Esta operación requiere verificación en dos pasos.") {
14
+ super(message);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Denegación de la política: sin sesión, sin permiso o sin segundo factor.
20
+ *
21
+ * Son los únicos errores que `onDenied` convierte en valor. Un fallo del
22
+ * portal (`SSOProtocolError`) o de configuración nunca se tragan: una caída
23
+ * no es una denegación y tratarla como tal echaría a usuarios por un 502.
24
+ */
25
+ export type AuthorizationError =
26
+ | SSOAuthRequiredError
27
+ | SSOForbiddenError
28
+ | SSOMfaRequiredError;
29
+
30
+ export function isAuthorizationError(
31
+ error: unknown,
32
+ ): error is AuthorizationError {
33
+ return (
34
+ error instanceof SSOAuthRequiredError ||
35
+ error instanceof SSOForbiddenError ||
36
+ error instanceof SSOMfaRequiredError
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Política de acceso declarativa. Todo deniega por defecto: solo se permite lo
42
+ * que se declara.
43
+ */
44
+ export type AccessPolicy<Permission extends string = string> = {
45
+ /** Permiso exigido. Sin sesión o sin permiso, se deniega. */
46
+ permission?: Permission | readonly Permission[];
47
+ /** Exige que todos los permisos listados estén presentes. Por defecto basta uno. */
48
+ mode?: "any" | "all";
49
+ /**
50
+ * Exige tener al menos un permiso de esta aplicación, cualquiera.
51
+ *
52
+ * El portal autentica a cualquier cuenta: haber entrado no significa tener acceso a
53
+ * esta app. Con `access: true` quien llega sin ningún permiso queda fuera (403), sin
54
+ * tener que escoger un permiso «de entrada» arbitrario.
55
+ */
56
+ access?: boolean;
57
+ /** Exige segundo factor (`amr` incluye `mfa`). */
58
+ requireMfa?: boolean;
59
+ /** Vuelve a preguntar al portal por permisos vivos. */
60
+ revalidate?: boolean;
61
+ };
62
+
63
+ /**
64
+ * Decide si una sesión cumple la política. No habla con la red: la frescura la
65
+ * decide quien llama (`revalidate` en `protect`/`action`).
66
+ *
67
+ * Lanza `SSOForbiddenError`, `SSOMfaRequiredError` o `SSOAuthRequiredError`.
68
+ * Devuelve la sesión para encadenar.
69
+ */
70
+ export function authorize<Permission extends string>(
71
+ session: SSOSession<Permission> | null,
72
+ policy: AccessPolicy<Permission> = {},
73
+ ): SSOSession<Permission> {
74
+ if (!session) throw new SSOAuthRequiredError();
75
+
76
+ if (policy.access && session.permissions.length === 0) {
77
+ throw new SSOForbiddenError("acceso");
78
+ }
79
+
80
+ const wanted = policy.permission
81
+ ? Array.isArray(policy.permission)
82
+ ? [...policy.permission]
83
+ : [policy.permission]
84
+ : [];
85
+
86
+ if (wanted.length > 0) {
87
+ const ok =
88
+ policy.mode === "all"
89
+ ? session.canAll(...wanted)
90
+ : session.canAny(...wanted);
91
+ if (!ok) {
92
+ const missing = wanted.find((p) => !session.can(p)) ?? wanted[0];
93
+ session.requires(missing as Permission);
94
+ }
95
+ }
96
+
97
+ if (policy.requireMfa && !session.usedSecondFactor()) {
98
+ throw new SSOMfaRequiredError();
99
+ }
100
+
101
+ return session;
102
+ }
package/src/react.tsx ADDED
@@ -0,0 +1,171 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+
3
+ import { type AccessPolicy, authorize } from "./policy";
4
+ import type { SSOSession } from "./types";
5
+
6
+ /** Lo mínimo que un componente necesita: resolver la sesión actual. */
7
+ export type SessionResolver<Permission extends string> = {
8
+ session(options?: {
9
+ revalidate?: boolean;
10
+ }): Promise<SSOSession<Permission> | null>;
11
+ };
12
+
13
+ export type ProtectProps<Permission extends string> =
14
+ AccessPolicy<Permission> & {
15
+ /** El cliente Next (`sso`). Explícito a propósito: sin contexto global que heredar. */
16
+ sso: SessionResolver<Permission>;
17
+ children: ReactNode;
18
+ /** Qué pintar cuando se deniega. Por defecto no pinta nada (niega por defecto). */
19
+ fallback?: ReactNode;
20
+ };
21
+
22
+ /**
23
+ * Guarda declarativa para React Server Components.
24
+ *
25
+ * ```tsx
26
+ * <Protect sso={sso} permission="documentos:anular" fallback={<p>Sin acceso.</p>}>
27
+ * <BotonAnular />
28
+ * </Protect>
29
+ * ```
30
+ *
31
+ * Nunca redirige ni lanza: denegar pinta `fallback` (o nada). Para páginas
32
+ * completas usa `sso.protectPage()`, que sí redirige a entrar.
33
+ */
34
+ export async function Protect<Permission extends string>({
35
+ sso,
36
+ children,
37
+ fallback = null,
38
+ ...policy
39
+ }: ProtectProps<Permission>): Promise<ReactNode> {
40
+ const session = await sso.session({ revalidate: policy.revalidate ?? false });
41
+
42
+ try {
43
+ authorize(session, policy);
44
+ return children;
45
+ } catch {
46
+ return fallback;
47
+ }
48
+ }
49
+
50
+ export type CanProps<Permission extends string> = AccessPolicy<Permission> & {
51
+ sso: SessionResolver<Permission>;
52
+ children: ReactNode;
53
+ fallback?: ReactNode;
54
+ };
55
+
56
+ /**
57
+ * Alias de `Protect` para permisos puntuales en la interfaz. Misma negación
58
+ * por defecto: sin permiso, `fallback` o nada.
59
+ */
60
+ export async function Can<Permission extends string>(
61
+ props: CanProps<Permission>,
62
+ ): Promise<ReactNode> {
63
+ return Protect(props);
64
+ }
65
+
66
+ /** Lo mínimo para pintar enlaces de entrada y salida. */
67
+ export type LinkResolver = {
68
+ signInPath(returnTo?: string): string;
69
+ signOutPath(): string;
70
+ };
71
+
72
+ type AnchorRest = Omit<ComponentProps<"a">, "href">;
73
+
74
+ export type SignInLinkProps = AnchorRest & {
75
+ /** El cliente Next (`sso`). */
76
+ sso: LinkResolver;
77
+ /** A dónde volver tras entrar. Por defecto, la raíz. */
78
+ returnTo?: string;
79
+ };
80
+
81
+ /**
82
+ * Enlace de entrada.
83
+ *
84
+ * ```tsx
85
+ * <SignInLink sso={sso} returnTo="/panel" className="boton">
86
+ * Entrar con el Portal SSO
87
+ * </SignInLink>
88
+ * ```
89
+ */
90
+ export function SignInLink({
91
+ sso,
92
+ returnTo,
93
+ children = "Entrar",
94
+ ...anchor
95
+ }: SignInLinkProps): ReactNode {
96
+ return (
97
+ <a {...anchor} href={sso.signInPath(returnTo)}>
98
+ {children}
99
+ </a>
100
+ );
101
+ }
102
+
103
+ export type SignOutLinkProps = AnchorRest & {
104
+ /** El cliente Next (`sso`). */
105
+ sso: LinkResolver;
106
+ };
107
+
108
+ /**
109
+ * Enlace de salida. Cierra la sesión local **y** la del portal: salir solo de
110
+ * la local dejaría a la persona dentro del portal y el siguiente entrar la
111
+ * devolvería sin pedir nada.
112
+ */
113
+ export function SignOutLink({
114
+ sso,
115
+ children = "Salir",
116
+ ...anchor
117
+ }: SignOutLinkProps): ReactNode {
118
+ return (
119
+ <a {...anchor} href={sso.signOutPath()}>
120
+ {children}
121
+ </a>
122
+ );
123
+ }
124
+
125
+ type BoundProps<Permission extends string> = AccessPolicy<Permission> & {
126
+ children: ReactNode;
127
+ fallback?: ReactNode;
128
+ };
129
+
130
+ /**
131
+ * Los componentes ya atados a un cliente: sin pasar `sso` en cada uso y con el catálogo de
132
+ * permisos tipado (una errata en `permission` no compila).
133
+ *
134
+ * ```tsx
135
+ * // lib/sso.ts
136
+ * export const { Can, SignedIn, SignedOut, SignInLink, SignOutLink } = sso.components;
137
+ *
138
+ * <SignedOut><SignInLink returnTo="/panel" /></SignedOut>
139
+ * <SignedIn><SignOutLink /></SignedIn>
140
+ * <Can permission="documentos:anular"><BotonAnular /></Can>
141
+ * ```
142
+ */
143
+ export function bindComponents<Permission extends string>(
144
+ sso: SessionResolver<Permission> & LinkResolver,
145
+ ) {
146
+ return {
147
+ /** Pinta los hijos si se cumple la política; si no, `fallback` o nada. */
148
+ Can: (props: BoundProps<Permission>) => Protect({ ...props, sso }),
149
+ /** Igual que `Can`; el nombre que lee mejor para bloques completos. */
150
+ Protect: (props: BoundProps<Permission>) => Protect({ ...props, sso }),
151
+
152
+ /** Solo con sesión. */
153
+ SignedIn: async ({
154
+ children,
155
+ }: {
156
+ children: ReactNode;
157
+ }): Promise<ReactNode> => ((await sso.session()) ? children : null),
158
+
159
+ /** Solo sin sesión. */
160
+ SignedOut: async ({
161
+ children,
162
+ }: {
163
+ children: ReactNode;
164
+ }): Promise<ReactNode> => ((await sso.session()) ? null : children),
165
+
166
+ SignInLink: (props: Omit<SignInLinkProps, "sso">) =>
167
+ SignInLink({ ...props, sso }),
168
+ SignOutLink: (props: Omit<SignOutLinkProps, "sso">) =>
169
+ SignOutLink({ ...props, sso }),
170
+ };
171
+ }
@@ -0,0 +1,86 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { EncryptJWT, jwtDecrypt } from "jose";
4
+
5
+ import { SSOConfigError } from "./errors";
6
+
7
+ /** Contenido que se guarda cifrado en la cookie. */
8
+ export type SessionPayload = {
9
+ sub: string;
10
+ email: string;
11
+ emailVerified: boolean;
12
+ name: string | null;
13
+ image: string | null;
14
+ roles: string[];
15
+ permissions: string[];
16
+ amr: string[];
17
+ accessToken: string;
18
+ refreshToken?: string;
19
+ /**
20
+ * El `id_token` de la sesión.
21
+ *
22
+ * Se guarda porque el cierre de sesión del portal lo exige: `end-session`
23
+ * necesita `id_token_hint` para saber qué sesión terminar, y sin él responde
24
+ * con un error y la sesión del portal sigue viva.
25
+ */
26
+ idToken?: string;
27
+ expiresAt: number;
28
+ };
29
+
30
+ /**
31
+ * Deriva la clave de cifrado a partir del secreto configurado.
32
+ *
33
+ * AES-256-GCM necesita exactamente 32 bytes, y un secreto escrito por una
34
+ * persona casi nunca los mide. Se pasa por SHA-256 para obtenerlos sin
35
+ * obligar a nadie a generar una clave con el tamaño justo.
36
+ */
37
+ function deriveKey(secret: string): Uint8Array {
38
+ if (secret.length < 32) {
39
+ throw new SSOConfigError(
40
+ "`secret` necesita al menos 32 caracteres. Genera uno con:\n" +
41
+ " node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"",
42
+ );
43
+ }
44
+
45
+ return new Uint8Array(createHash("sha256").update(secret).digest());
46
+ }
47
+
48
+ /**
49
+ * Cifra la sesión para guardarla en una cookie.
50
+ *
51
+ * Se cifra y no solo se firma porque el contenido incluye el access token. Una
52
+ * cookie firmada es legible por cualquiera que la tenga delante: un volcado de
53
+ * memoria, un log de proxy mal configurado o una copia del perfil del
54
+ * navegador bastarían para llevárselo.
55
+ */
56
+ export async function sealSession(
57
+ payload: SessionPayload,
58
+ secret: string,
59
+ maxAgeSeconds: number,
60
+ ): Promise<string> {
61
+ return new EncryptJWT({ ...payload })
62
+ .setProtectedHeader({ alg: "dir", enc: "A256GCM" })
63
+ .setIssuedAt()
64
+ .setExpirationTime(`${maxAgeSeconds}s`)
65
+ .encrypt(deriveKey(secret));
66
+ }
67
+
68
+ /**
69
+ * Descifra la cookie. Devuelve `null` si está corrupta, caducada o cifrada con
70
+ * otro secreto, en lugar de lanzar: para quien llama, todos esos casos
71
+ * significan lo mismo —no hay sesión utilizable— y tratarlos por separado solo
72
+ * añadiría ramas sin ninguna decisión distinta detrás.
73
+ */
74
+ export async function openSession(
75
+ token: string | undefined,
76
+ secret: string,
77
+ ): Promise<SessionPayload | null> {
78
+ if (!token) return null;
79
+
80
+ try {
81
+ const { payload } = await jwtDecrypt(token, deriveKey(secret));
82
+ return payload as unknown as SessionPayload;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }