@dacyti-gsi/auth 0.2.0 → 0.3.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/index.ts CHANGED
@@ -1,673 +1,671 @@
1
- import { resolveOptions, validateOptions } from "./config";
2
- import {
3
- authorizationUrl,
4
- buildSession,
5
- discover,
6
- exchangeCode,
7
- fetchUserInfo,
8
- refreshOnce,
9
- refreshTokens,
10
- sessionFromPayload,
11
- type TokenSet,
12
- } from "./core";
13
- import { isCredentialRejected, SSOError } from "./errors";
14
- import { createAuthorizationAttempt, statesMatch } from "./pkce";
15
- import {
16
- openSession,
17
- type SessionPayload,
18
- sealSession,
19
- } from "./session-cookie";
20
- import type { SSOClientOptions, SSOSession } from "./types";
21
-
22
- export {
23
- type ResolvedSSOOptions,
24
- resolveOptions,
25
- validateOptions,
26
- } from "./config";
27
- export {
28
- clearDiscoveryCache,
29
- clientCredentialsToken,
30
- introspectToken,
31
- } from "./core";
32
- export {
33
- SSOConfigError,
34
- SSOError,
35
- SSOForbiddenError,
36
- SSOProtocolError,
37
- } from "./errors";
38
- export {
39
- type AccessPolicy,
40
- authorize,
41
- SSOAuthRequiredError,
42
- SSOMfaRequiredError,
43
- } from "./policy";
44
- export type {
45
- AuthMethod,
46
- SSOClientOptions,
47
- SSOSession,
48
- SSOUser,
49
- } from "./types";
50
-
51
- /**
52
- * `offline_access` va por defecto a propósito.
53
- *
54
- * Es lo que hace que el portal emita un `refresh_token`. Sin él, el access token
55
- * dura quince minutos y no hay forma de renovarlo: la aplicación se quedaría sin
56
- * acceso a las APIs a los quince minutos y solo podría recuperarlo volviendo a
57
- * mandar al usuario a autenticarse.
58
- */
59
- const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"];
60
- const DEFAULT_BASE_PATH = "/api/sso";
61
- const DEFAULT_COOKIE = "__Host-sso_session";
62
- const DEFAULT_MAX_AGE = 7 * 24 * 60 * 60;
63
-
64
- /**
65
- * Ventana de gracia con el portal caído.
66
- *
67
- * Cuando el portal no responde, la sesión se sirve de la cookie sin poder
68
- * revalidar (fail-open deliberado por disponibilidad). Sin tope, una caída
69
- * prolongada congelaría autorizaciones —incluidos roles ya revocados— hasta
70
- * agotar la cookie. Pasada esta holgura desde que caducó el acceso, la sesión
71
- * local deja de aceptarse y se pide autenticarse de nuevo.
72
- */
73
- export const MAX_STALE_SECONDS = 60 * 60;
74
-
75
- let lastStaleWarningAt = 0;
76
-
77
- /**
78
- * Si una sesión en caché superó la ventana de gracia, avisa (como mucho una
79
- * vez por minuto) y pide tratarla como terminada.
80
- */
81
- export function warnIfStaleSession(expiresAtSeconds: number): boolean {
82
- if (Date.now() / 1000 - expiresAtSeconds <= MAX_STALE_SECONDS) return false;
83
-
84
- if (Date.now() - lastStaleWarningAt > 60_000) {
85
- lastStaleWarningAt = Date.now();
86
- console.warn(
87
- "[dacyti-auth] el portal no responde y la sesión en caché superó la ventana de gracia: se exige autenticarse de nuevo en lugar de servir permisos congelados.",
88
- );
89
- }
90
-
91
- return true;
92
- }
93
-
94
- /**
95
- * Cookie temporal donde viven el verifier y el state entre ida y vuelta. Lleva el `state` en el
96
- * nombre: con una sola cookie, abrir dos pestañas a la vez haría que la segunda pisara el intento
97
- * de la primera y esta fallara al volver.
98
- */
99
- const ATTEMPT_COOKIE = "sso_attempt";
100
- const ATTEMPT_MAX_AGE = 10 * 60;
101
- const STATE_SHAPE = /^[A-Za-z0-9_-]{8,64}$/;
102
- const attemptCookieName = (state: string) => `${ATTEMPT_COOKIE}_${state}`;
103
-
104
- /**
105
- * Los navegadores descartan sin avisar una cookie que pasa de ~4 KB, y la sesión guarda tres
106
- * tokens: si algún día no cabe, el síntoma sería un bucle infinito de login. Mejor un error claro.
107
- */
108
- const MAX_COOKIE_BYTES = 4000;
109
-
110
- export type ResolvedSession<Permission extends string = string> = {
111
- session: SSOSession<Permission>;
112
- /**
113
- * Cookie nueva, ya sellada. `null` si nada cambió y la anterior sigue valiendo.
114
- *
115
- * Quien pueda escribir cookies —una Server Action, un route handler— debe
116
- * guardarla; quien no pueda, al menos tiene la sesión correcta para esta
117
- * respuesta.
118
- */
119
- cookie: string | null;
120
- };
121
-
122
- export type SSOClient<Permission extends string = string> = {
123
- readonly basePath: string;
124
- readonly cookieName: string;
125
- /** Duración de la sesión local, en segundos. */
126
- readonly sessionMaxAge: number;
127
-
128
- /** URL a la que enviar a alguien para autenticarse. */
129
- signInPath(returnTo?: string): string;
130
- /** URL para cerrar la sesión local y la del portal. */
131
- signOutPath(): string;
132
-
133
- /** Reconstruye la sesión desde la cookie. `null` si no hay o caducó. */
134
- readSession(
135
- cookieValue: string | undefined,
136
- ): Promise<SSOSession<Permission> | null>;
137
-
138
- /**
139
- * Sesión al día: renueva el acceso caducado y, si se pide, vuelve a consultar
140
- * los permisos al portal.
141
- *
142
- * `readSession` es un descifrado local y no habla con el portal, así que sirve
143
- * una foto que puede tener días. Esto es lo contrario: comprueba contra el
144
- * portal y devuelve permisos vivos.
145
- */
146
- resolveSession(
147
- cookieValue: string | undefined,
148
- options?: { revalidate?: boolean },
149
- ): Promise<ResolvedSession<Permission> | null>;
150
-
151
- /** Atiende las rutas del cliente. Devuelve `null` si la ruta no le compete. */
152
- handleRequest(request: Request): Promise<Response | null>;
153
- };
154
-
155
- /**
156
- * Declara el cliente del portal.
157
- *
158
- * Sin ceremonia: lo que no se pasa se lee del entorno.
159
- *
160
- * ```ts
161
- * export const sso = defineSSO({ permissions: ["documentos:crear", "documentos:anular"] });
162
- * // issuer ← SSO_ISSUER, clientId ← SSO_CLIENT_ID,
163
- * // clientSecret ← SSO_CLIENT_SECRET, secret ← SSO_SECRET
164
- * ```
165
- *
166
- * Pasar `permissions` como tupla `as const` hace que `can()` autocomplete y que
167
- * un permiso mal escrito falle al compilar, en lugar de devolver `false` en
168
- * silencio en producción.
169
- */
170
- export function defineSSO<const Permission extends string = string>(
171
- options: SSOClientOptions<Permission>,
172
- ): SSOClient<Permission> {
173
- const resolved = resolveOptions(options);
174
- validateOptions(resolved);
175
-
176
- const basePath = (resolved.basePath ?? DEFAULT_BASE_PATH).replace(/\/$/, "");
177
- const cookieName = resolved.cookieName ?? DEFAULT_COOKIE;
178
- const scopes = resolved.scopes ?? DEFAULT_SCOPES;
179
- const maxAge = resolved.sessionMaxAge ?? DEFAULT_MAX_AGE;
180
-
181
- /**
182
- * Origen público de la aplicación.
183
- *
184
- * Se deduce de la petición para no obligar a configurar una variable más. Se
185
- * respeta `X-Forwarded-*` porque detrás de un proxy la URL de la petición
186
- * lleva el host interno, y el `redirect_uri` resultante no coincidiría con el
187
- * registrado.
188
- */
189
- function originOf(request: Request): string {
190
- if (resolved.appUrl) return resolved.appUrl.replace(/\/$/, "");
191
-
192
- const url = new URL(request.url);
193
- const host = request.headers.get("x-forwarded-host") ?? url.host;
194
- const protocol =
195
- request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
196
-
197
- return `${protocol}://${host}`;
198
- }
199
-
200
- const redirectUriFor = (request: Request) =>
201
- `${originOf(request)}${basePath}/callback`;
202
-
203
- function readCookie(request: Request, name: string): string | undefined {
204
- const header = request.headers.get("cookie");
205
- if (!header) return undefined;
206
-
207
- for (const part of header.split(";")) {
208
- const trimmed = part.trim();
209
- const separator = trimmed.indexOf("=");
210
- if (separator > 0 && trimmed.slice(0, separator) === name) {
211
- // Una cookie con `%` mal formado no debe tumbar la petición: vale como ausente.
212
- try {
213
- return decodeURIComponent(trimmed.slice(separator + 1));
214
- } catch {
215
- return undefined;
216
- }
217
- }
218
- }
219
-
220
- return undefined;
221
- }
222
-
223
- function cookieHeader(
224
- name: string,
225
- value: string,
226
- seconds: number,
227
- secure: boolean,
228
- ): string {
229
- const encoded = encodeURIComponent(value);
230
- if (encoded.length > MAX_COOKIE_BYTES) {
231
- throw new SSOError(
232
- `La cookie ${name} pesa ${encoded.length} bytes y el navegador la descartaría (límite ~4096). ` +
233
- "Reduce los permisos o scopes de la sesión.",
234
- );
235
- }
236
-
237
- const attributes = [
238
- `${name}=${encoded}`,
239
- "Path=/",
240
- "HttpOnly",
241
- "SameSite=Lax",
242
- `Max-Age=${seconds}`,
243
- ];
244
-
245
- // `__Host-` exige `Secure` y `Path=/` sin `Domain`: el navegador la
246
- // rechaza sin aviso si falta. Se fuerza siempre.
247
- if (secure || name.startsWith("__Host-")) attributes.push("Secure");
248
-
249
- return attributes.join("; ");
250
- }
251
-
252
- /** El destino tras autenticarse solo puede ser una ruta de esta aplicación. */
253
- function safeReturnTo(value: string | null): string {
254
- if (!value) return "/";
255
- if (!value.startsWith("/")) return "/";
256
- if (value.startsWith("//")) return "/";
257
- if (value.includes("\\")) return "/";
258
-
259
- return value;
260
- }
261
-
262
- async function handleSignIn(request: Request): Promise<Response> {
263
- const discovery = await discover(resolved.issuer);
264
- const attempt = createAuthorizationAttempt();
265
- const returnTo = safeReturnTo(new URL(request.url).searchParams.get("to"));
266
-
267
- const target = authorizationUrl({
268
- discovery,
269
- clientId: resolved.clientId,
270
- redirectUri: redirectUriFor(request),
271
- scopes,
272
- challenge: attempt.challenge,
273
- state: attempt.state,
274
- });
275
-
276
- const secure = originOf(request).startsWith("https://");
277
- const attemptValue = JSON.stringify({
278
- verifier: attempt.verifier,
279
- state: attempt.state,
280
- returnTo,
281
- });
282
-
283
- return new Response(null, {
284
- status: 302,
285
- headers: {
286
- location: target,
287
- "set-cookie": cookieHeader(
288
- attemptCookieName(attempt.state),
289
- attemptValue,
290
- ATTEMPT_MAX_AGE,
291
- secure,
292
- ),
293
- },
294
- });
295
- }
296
-
297
- async function handleCallback(request: Request): Promise<Response> {
298
- const url = new URL(request.url);
299
- const code = url.searchParams.get("code");
300
- const state = url.searchParams.get("state");
301
-
302
- // El `state` forma parte del nombre de la cookie: se valida su forma antes de usarlo.
303
- const stateOk = state !== null && STATE_SHAPE.test(state);
304
- const raw = stateOk ? readCookie(request, attemptCookieName(state)) : undefined;
305
- const secure = originOf(request).startsWith("https://");
306
- const clearAttempt = stateOk
307
- ? cookieHeader(attemptCookieName(state), "", 0, secure)
308
- : null;
309
-
310
- // La persona canceló o el portal rechazó la autorización (`access_denied`, etc.).
311
- if (url.searchParams.get("error")) {
312
- return new Response(
313
- "El portal no autorizó el ingreso. Vuelve a intentarlo.",
314
- {
315
- status: 403,
316
- headers: clearAttempt ? { "set-cookie": clearAttempt } : undefined,
317
- },
318
- );
319
- }
320
-
321
- /**
322
- * La cookie del intento la escribió esta aplicación, pero viaja por el
323
- * navegador: puede llegar truncada, editada o de una versión anterior del
324
- * SDK. Sin este `try`, un `JSON.parse` sobre basura subía como error de
325
- * servidor donde lo que corresponde es decir que el intento no vale.
326
- */
327
- let attempt: { verifier: string; state: string; returnTo: string } | null =
328
- null;
329
- try {
330
- attempt = raw
331
- ? (JSON.parse(raw) as {
332
- verifier: string;
333
- state: string;
334
- returnTo: string;
335
- })
336
- : null;
337
- } catch {
338
- attempt = null;
339
- }
340
-
341
- /**
342
- * Sin intento guardado, o con un `state` que no coincide, se aborta.
343
- *
344
- * Esa comprobación es lo que impide que un tercero fuerce a alguien a
345
- * completar un flujo que empezó él: sin ella, un atacante puede hacer que
346
- * la víctima acabe con la sesión del atacante iniciada en su navegador.
347
- */
348
- if (!code || !state || !attempt || !statesMatch(attempt.state, state)) {
349
- return new Response("Petición de autenticación inválida o caducada.", {
350
- status: 400,
351
- });
352
- }
353
-
354
- let tokens: TokenSet;
355
- let session: SSOSession<Permission>;
356
- try {
357
- const discovery = await discover(resolved.issuer);
358
-
359
- tokens = await exchangeCode({
360
- discovery,
361
- clientId: resolved.clientId,
362
- clientSecret: resolved.clientSecret,
363
- code,
364
- verifier: attempt.verifier,
365
- redirectUri: redirectUriFor(request),
366
- });
367
-
368
- const profile = await fetchUserInfo({
369
- discovery,
370
- accessToken: tokens.accessToken,
371
- });
372
- session = buildSession<Permission>(profile, tokens);
373
- } catch (error: unknown) {
374
- // Código ya usado, caducado o portal caído: el intento no se puede reanudar, pero tampoco
375
- // debe llegar al navegador un 500 con el detalle del portal.
376
- console.error("[sso] el canje del código falló:", error);
377
- return new Response(
378
- "No se pudo completar el ingreso con el portal. Vuelve a intentarlo.",
379
- {
380
- status: 502,
381
- headers: clearAttempt ? { "set-cookie": clearAttempt } : undefined,
382
- },
383
- );
384
- }
385
-
386
- const payload: SessionPayload = {
387
- sub: session.user.id,
388
- email: session.user.email,
389
- emailVerified: session.user.emailVerified,
390
- name: session.user.name,
391
- image: session.user.image,
392
- roles: session.roles,
393
- permissions: session.permissions,
394
- amr: session.amr,
395
- accessToken: session.accessToken,
396
- refreshToken: tokens.refreshToken,
397
- // Se guarda porque `end-session` lo exige para cerrar la sesión del portal.
398
- idToken: tokens.idToken,
399
- expiresAt: session.expiresAt,
400
- };
401
-
402
- return new Response(null, {
403
- status: 302,
404
- headers: [
405
- ["location", `${originOf(request)}${safeReturnTo(attempt.returnTo)}`],
406
- [
407
- "set-cookie",
408
- cookieHeader(
409
- cookieName,
410
- await sealSession(payload, resolved.secret, maxAge),
411
- maxAge,
412
- secure,
413
- ),
414
- ],
415
- // El intento ya se consumió: dejarlo vivo permitiría reutilizarlo.
416
- ["set-cookie", cookieHeader(attemptCookieName(state), "", 0, secure)],
417
- ],
418
- });
419
- }
420
-
421
- /**
422
- * Cierre de sesión en la aplicación **y** en el portal.
423
- *
424
- * Antes se redirigía a `end_session` sin parámetros, y el portal los exige:
425
- * responde con un error de validación, así que la cookie local se borraba y
426
- * la sesión del portal seguía viva. El efecto era el peor posible: el usuario
427
- * creía haber salido y el siguiente «Entrar» lo dejaba dentro sin pedir nada.
428
- *
429
- * `id_token_hint` es lo que identifica qué sesión terminar, así que se lee de
430
- * la cookie antes de borrarla. Sin él, `end-session` no tiene forma de saber
431
- * de quién es la sesión y no se le puede pedir que la cierre.
432
- */
433
- async function handleSignOut(request: Request): Promise<Response> {
434
- const origin = originOf(request);
435
- const secure = origin.startsWith("https://");
436
-
437
- const payload = await openSession(
438
- readCookie(request, cookieName),
439
- resolved.secret,
440
- );
441
-
442
- // Sin sesión local no hay nada que cerrar en el portal, así que ni se le
443
- // pregunta: salir de una sesión que no existe no necesita una ida y vuelta.
444
- let location = origin;
445
-
446
- if (payload?.idToken) {
447
- const discovery = await discover(resolved.issuer);
448
-
449
- if (discovery.end_session_endpoint) {
450
- const target = new URL(discovery.end_session_endpoint);
451
- target.searchParams.set("id_token_hint", payload.idToken);
452
- target.searchParams.set("client_id", resolved.clientId);
453
-
454
- /**
455
- * Solo se pide volver a la aplicación si se declaró una URI registrada.
456
- * El portal rechaza cualquier destino que no lo esté, así que mandarlo
457
- * a ciegas cambiaría una salida limpia por una pantalla de error.
458
- */
459
- if (resolved.postLogoutRedirectUri) {
460
- target.searchParams.set(
461
- "post_logout_redirect_uri",
462
- resolved.postLogoutRedirectUri,
463
- );
464
- }
465
-
466
- location = target.toString();
467
- }
468
- }
469
-
470
- return new Response(null, {
471
- status: 302,
472
- headers: {
473
- location,
474
- "set-cookie": cookieHeader(cookieName, "", 0, secure),
475
- },
476
- });
477
- }
478
-
479
- /**
480
- * Devuelve la sesión al día, hablando con el portal solo cuando hace falta.
481
- *
482
- * Dos cosas distintas pueden estar rancias:
483
- *
484
- * - El `access_token`, que dura quince minutos. Se renueva con el
485
- * `refresh_token` sin molestar al usuario.
486
- * - Los permisos, que son una foto del momento en que se autorizó. Se vuelven
487
- * a pedir cuando se pasa `revalidate`, que es lo que hace que revocar un rol
488
- * surta efecto de inmediato en lugar de esperar a que caduque la cookie.
489
- *
490
- * Si el portal rechaza la credencial —token revocado, sesión cerrada, cuenta
491
- * bloqueada— se devuelve `null`: la sesión terminó. Un fallo de red o un error
492
- * del servidor, en cambio, se propaga: son un mal momento, no una razón para
493
- * cerrar la sesión de nadie.
494
- */
495
- async function resolveSession(
496
- cookieValue: string | undefined,
497
- resolution: { revalidate?: boolean } = {},
498
- ): Promise<ResolvedSession<Permission> | null> {
499
- const payload = await openSession(cookieValue, resolved.secret);
500
- if (!payload) return null;
501
-
502
- const expired = payload.expiresAt * 1000 <= Date.now();
503
-
504
- // Camino rápido: ni caducó ni se pidió frescura, así que el descifrado local
505
- // basta y no se añade una ida y vuelta a cada petición.
506
- if (!expired && !resolution.revalidate) {
507
- return { session: sessionFromPayload<Permission>(payload), cookie: null };
508
- }
509
-
510
- let accessToken = payload.accessToken;
511
- let refreshToken = payload.refreshToken;
512
- let idToken = payload.idToken;
513
- let expiresAt = payload.expiresAt;
514
-
515
- // Sin refresh token no queda nada que canjear: la sesión ya no sirve ni
516
- // para llamar al portal. Se decide antes de pedir el descubrimiento, para
517
- // no gastar una ida y vuelta en un caso ya resuelto.
518
- if (expired && !refreshToken) return null;
519
-
520
- const discovery = await discover(resolved.issuer);
521
-
522
- if (expired && refreshToken) {
523
- try {
524
- const current = refreshToken;
525
- const tokens = await refreshOnce(current, () =>
526
- refreshTokens({
527
- discovery,
528
- clientId: resolved.clientId,
529
- clientSecret: resolved.clientSecret,
530
- refreshToken: current,
531
- }),
532
- );
533
-
534
- accessToken = tokens.accessToken;
535
- refreshToken = tokens.refreshToken ?? refreshToken;
536
- idToken = tokens.idToken ?? idToken;
537
- expiresAt = tokens.expiresAt;
538
- } catch (error: unknown) {
539
- if (isCredentialRejected(error)) return null;
540
- if (warnIfStaleSession(payload.expiresAt)) return null;
541
- throw error;
542
- }
543
- }
544
-
545
- let profile: Record<string, unknown>;
546
- try {
547
- profile = await fetchUserInfo({ discovery, accessToken });
548
- } catch (error: unknown) {
549
- if (isCredentialRejected(error)) return null;
550
- if (warnIfStaleSession(payload.expiresAt)) return null;
551
- throw error;
552
- }
553
-
554
- const session = buildSession<Permission>(profile, {
555
- accessToken,
556
- expiresAt,
557
- });
558
-
559
- const next: SessionPayload = {
560
- sub: session.user.id,
561
- email: session.user.email,
562
- emailVerified: session.user.emailVerified,
563
- name: session.user.name,
564
- image: session.user.image,
565
- roles: session.roles,
566
- permissions: session.permissions,
567
- amr: session.amr,
568
- accessToken,
569
- refreshToken,
570
- idToken,
571
- expiresAt,
572
- };
573
-
574
- return {
575
- session,
576
- cookie: await sealSession(next, resolved.secret, maxAge),
577
- };
578
- }
579
-
580
- /**
581
- * Renueva y vuelve a la aplicación.
582
- *
583
- * Es la única forma de persistir la renovación desde cualquier sitio: las
584
- * pantallas de servidor no pueden escribir cookies, así que si el refresco
585
- * ocurre ahí, el resultado se pierde al terminar la respuesta.
586
- */
587
- async function handleRefresh(request: Request): Promise<Response> {
588
- const url = new URL(request.url);
589
- const secure = originOf(request).startsWith("https://");
590
- const returnTo = safeReturnTo(url.searchParams.get("to"));
591
-
592
- let resolved: ResolvedSession<Permission> | null;
593
- try {
594
- resolved = await resolveSession(readCookie(request, cookieName), {
595
- revalidate: true,
596
- });
597
- } catch {
598
- // El portal no respondió. No se toca la cookie: la sesión sigue siendo la
599
- // de antes y borrarla convertiría una caída en una sesión perdida.
600
- return new Response("No se pudo comprobar la sesión con el portal.", {
601
- status: 503,
602
- });
603
- }
604
-
605
- const headers: Array<[string, string]> = [
606
- ["location", `${originOf(request)}${returnTo}`],
607
- ];
608
-
609
- if (!resolved) {
610
- headers.push(["set-cookie", cookieHeader(cookieName, "", 0, secure)]);
611
- } else if (resolved.cookie) {
612
- headers.push([
613
- "set-cookie",
614
- cookieHeader(cookieName, resolved.cookie, maxAge, secure),
615
- ]);
616
- }
617
-
618
- return new Response(null, { status: 302, headers });
619
- }
620
-
621
- return {
622
- basePath,
623
- cookieName,
624
- sessionMaxAge: maxAge,
625
-
626
- signInPath(returnTo) {
627
- return returnTo
628
- ? `${basePath}/sign-in?to=${encodeURIComponent(returnTo)}`
629
- : `${basePath}/sign-in`;
630
- },
631
-
632
- signOutPath() {
633
- return `${basePath}/sign-out`;
634
- },
635
-
636
- async readSession(cookieValue) {
637
- const payload = await openSession(cookieValue, resolved.secret);
638
- if (!payload) return null;
639
-
640
- return sessionFromPayload<Permission>(payload);
641
- },
642
-
643
- resolveSession,
644
-
645
- async handleRequest(request) {
646
- const { pathname } = new URL(request.url);
647
- if (!pathname.startsWith(`${basePath}/`)) return null;
648
-
649
- const route = pathname.slice(basePath.length + 1);
650
- const handler =
651
- route === "sign-in"
652
- ? handleSignIn
653
- : route === "callback"
654
- ? handleCallback
655
- : route === "sign-out"
656
- ? handleSignOut
657
- : route === "refresh"
658
- ? handleRefresh
659
- : null;
660
- if (!handler) return null;
661
-
662
- try {
663
- return await handler(request);
664
- } catch (error: unknown) {
665
- // Portal caído o colgado: respuesta controlada, sin pila ni detalles hacia el navegador.
666
- console.error(`[sso] ${route} falló:`, error);
667
- return new Response("El portal SSO no responde. Intenta de nuevo.", {
668
- status: 503,
669
- });
670
- }
671
- },
672
- };
673
- }
1
+ import { resolveOptions, validateOptions } from "./config";
2
+ import {
3
+ authorizationUrl,
4
+ buildSession,
5
+ discover,
6
+ exchangeCode,
7
+ fetchUserInfo,
8
+ refreshOnce,
9
+ refreshTokens,
10
+ sessionFromPayload,
11
+ type TokenSet,
12
+ } from "./core";
13
+ import { isCredentialRejected, SSOError } from "./errors";
14
+ import { createAuthorizationAttempt, statesMatch } from "./pkce";
15
+ import {
16
+ openSession,
17
+ type SessionPayload,
18
+ sealSession,
19
+ } from "./session-cookie";
20
+ import type { SSOClientOptions, SSOSession } from "./types";
21
+
22
+ export {
23
+ type ResolvedSSOOptions,
24
+ resolveOptions,
25
+ validateOptions,
26
+ } from "./config";
27
+ export {
28
+ clearDiscoveryCache,
29
+ clientCredentialsToken,
30
+ introspectToken,
31
+ } from "./core";
32
+ export {
33
+ SSOConfigError,
34
+ SSOError,
35
+ SSOForbiddenError,
36
+ SSOProtocolError,
37
+ } from "./errors";
38
+ export {
39
+ type AccessPolicy,
40
+ authorize,
41
+ SSOAuthRequiredError,
42
+ SSOMfaRequiredError,
43
+ } from "./policy";
44
+ export type {
45
+ AuthMethod,
46
+ SSOClientOptions,
47
+ SSOSession,
48
+ SSOUser,
49
+ } from "./types";
50
+
51
+ /**
52
+ * `offline_access` va por defecto a propósito.
53
+ *
54
+ * Es lo que hace que el portal emita un `refresh_token`. Sin él, el access token
55
+ * dura quince minutos y no hay forma de renovarlo: la aplicación se quedaría sin
56
+ * acceso a las APIs a los quince minutos y solo podría recuperarlo volviendo a
57
+ * mandar al usuario a autenticarse.
58
+ */
59
+ const DEFAULT_SCOPES = ["openid", "profile", "email", "offline_access"];
60
+ const DEFAULT_BASE_PATH = "/api/sso";
61
+ const DEFAULT_COOKIE = "__Host-sso_session";
62
+ const DEFAULT_MAX_AGE = 7 * 24 * 60 * 60;
63
+
64
+ /**
65
+ * Ventana de gracia con el portal caído.
66
+ *
67
+ * Cuando el portal no responde, la sesión se sirve de la cookie sin poder
68
+ * revalidar (fail-open deliberado por disponibilidad). Sin tope, una caída
69
+ * prolongada congelaría autorizaciones —incluidos roles ya revocados— hasta
70
+ * agotar la cookie. Pasada esta holgura desde que caducó el acceso, la sesión
71
+ * local deja de aceptarse y se pide autenticarse de nuevo.
72
+ */
73
+ export const MAX_STALE_SECONDS = 60 * 60;
74
+
75
+ let lastStaleWarningAt = 0;
76
+
77
+ /**
78
+ * Si una sesión en caché superó la ventana de gracia, avisa (como mucho una
79
+ * vez por minuto) y pide tratarla como terminada.
80
+ */
81
+ export function warnIfStaleSession(expiresAtSeconds: number): boolean {
82
+ if (Date.now() / 1000 - expiresAtSeconds <= MAX_STALE_SECONDS) return false;
83
+
84
+ if (Date.now() - lastStaleWarningAt > 60_000) {
85
+ lastStaleWarningAt = Date.now();
86
+ console.warn(
87
+ "[dacyti-auth] el portal no responde y la sesión en caché superó la ventana de gracia: se exige autenticarse de nuevo en lugar de servir permisos congelados.",
88
+ );
89
+ }
90
+
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Cookie temporal donde viven el verifier y el state entre ida y vuelta. Lleva el `state` en el
96
+ * nombre: con una sola cookie, abrir dos pestañas a la vez haría que la segunda pisara el intento
97
+ * de la primera y esta fallara al volver.
98
+ */
99
+ const ATTEMPT_COOKIE = "sso_attempt";
100
+ const ATTEMPT_MAX_AGE = 10 * 60;
101
+ const STATE_SHAPE = /^[A-Za-z0-9_-]{8,64}$/;
102
+ const attemptCookieName = (state: string) => `${ATTEMPT_COOKIE}_${state}`;
103
+
104
+ /**
105
+ * Los navegadores descartan sin avisar una cookie que pasa de ~4 KB, y la sesión guarda tres
106
+ * tokens: si algún día no cabe, el síntoma sería un bucle infinito de login. Mejor un error claro.
107
+ */
108
+ const MAX_COOKIE_BYTES = 4000;
109
+
110
+ export type ResolvedSession<Permission extends string = string> = {
111
+ session: SSOSession<Permission>;
112
+ /**
113
+ * Cookie nueva, ya sellada. `null` si nada cambió y la anterior sigue valiendo.
114
+ *
115
+ * Quien pueda escribir cookies —una Server Action, un route handler— debe
116
+ * guardarla; quien no pueda, al menos tiene la sesión correcta para esta
117
+ * respuesta.
118
+ */
119
+ cookie: string | null;
120
+ };
121
+
122
+ export type SSOClient<Permission extends string = string> = {
123
+ readonly basePath: string;
124
+ readonly cookieName: string;
125
+ /** Duración de la sesión local, en segundos. */
126
+ readonly sessionMaxAge: number;
127
+
128
+ /** URL a la que enviar a alguien para autenticarse. */
129
+ signInPath(returnTo?: string): string;
130
+ /** URL para cerrar la sesión local y la del portal. */
131
+ signOutPath(): string;
132
+
133
+ /** Reconstruye la sesión desde la cookie. `null` si no hay o caducó. */
134
+ readSession(
135
+ cookieValue: string | undefined,
136
+ ): Promise<SSOSession<Permission> | null>;
137
+
138
+ /**
139
+ * Sesión al día: renueva el acceso caducado y, si se pide, vuelve a consultar
140
+ * los permisos al portal.
141
+ *
142
+ * `readSession` es un descifrado local y no habla con el portal, así que sirve
143
+ * una foto que puede tener días. Esto es lo contrario: comprueba contra el
144
+ * portal y devuelve permisos vivos.
145
+ */
146
+ resolveSession(
147
+ cookieValue: string | undefined,
148
+ options?: { revalidate?: boolean },
149
+ ): Promise<ResolvedSession<Permission> | null>;
150
+
151
+ /** Atiende las rutas del cliente. Devuelve `null` si la ruta no le compete. */
152
+ handleRequest(request: Request): Promise<Response | null>;
153
+ };
154
+
155
+ /**
156
+ * Declara el cliente del portal.
157
+ *
158
+ * Sin ceremonia: lo que no se pasa se lee del entorno.
159
+ *
160
+ * ```ts
161
+ * export const sso = defineSSO({ permissions: ["documentos:crear", "documentos:anular"] });
162
+ * // issuer ← SSO_ISSUER, clientId ← SSO_CLIENT_ID,
163
+ * // clientSecret ← SSO_CLIENT_SECRET, secret ← SSO_SECRET
164
+ * ```
165
+ *
166
+ * Pasar `permissions` como tupla `as const` hace que `can()` autocomplete y que
167
+ * un permiso mal escrito falle al compilar, en lugar de devolver `false` en
168
+ * silencio en producción.
169
+ */
170
+ export function defineSSO<const Permission extends string = string>(
171
+ options: SSOClientOptions<Permission>,
172
+ ): SSOClient<Permission> {
173
+ const resolved = resolveOptions(options);
174
+ validateOptions(resolved);
175
+
176
+ const basePath = (resolved.basePath ?? DEFAULT_BASE_PATH).replace(/\/$/, "");
177
+ const cookieName = resolved.cookieName ?? DEFAULT_COOKIE;
178
+ const scopes = resolved.scopes ?? DEFAULT_SCOPES;
179
+ const maxAge = resolved.sessionMaxAge ?? DEFAULT_MAX_AGE;
180
+
181
+ /**
182
+ * Origen público de la aplicación.
183
+ *
184
+ * Se deduce de la petición para no obligar a configurar una variable más. Se
185
+ * respeta `X-Forwarded-*` porque detrás de un proxy la URL de la petición
186
+ * lleva el host interno, y el `redirect_uri` resultante no coincidiría con el
187
+ * registrado.
188
+ */
189
+ function originOf(request: Request): string {
190
+ if (resolved.appUrl) return resolved.appUrl.replace(/\/$/, "");
191
+
192
+ const url = new URL(request.url);
193
+ const host = request.headers.get("x-forwarded-host") ?? url.host;
194
+ const protocol =
195
+ request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
196
+
197
+ return `${protocol}://${host}`;
198
+ }
199
+
200
+ const redirectUriFor = (request: Request) =>
201
+ `${originOf(request)}${basePath}/callback`;
202
+
203
+ function readCookie(request: Request, name: string): string | undefined {
204
+ const header = request.headers.get("cookie");
205
+ if (!header) return undefined;
206
+
207
+ for (const part of header.split(";")) {
208
+ const trimmed = part.trim();
209
+ const separator = trimmed.indexOf("=");
210
+ if (separator > 0 && trimmed.slice(0, separator) === name) {
211
+ // Una cookie con `%` mal formado no debe tumbar la petición: vale como ausente.
212
+ try {
213
+ return decodeURIComponent(trimmed.slice(separator + 1));
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ }
218
+ }
219
+
220
+ return undefined;
221
+ }
222
+
223
+ function cookieHeader(
224
+ name: string,
225
+ value: string,
226
+ seconds: number,
227
+ secure: boolean,
228
+ ): string {
229
+ const encoded = encodeURIComponent(value);
230
+ if (encoded.length > MAX_COOKIE_BYTES) {
231
+ throw new SSOError(
232
+ `La cookie ${name} pesa ${encoded.length} bytes y el navegador la descartaría (límite ~4096). ` +
233
+ "Reduce los permisos o scopes de la sesión.",
234
+ );
235
+ }
236
+
237
+ const attributes = [
238
+ `${name}=${encoded}`,
239
+ "Path=/",
240
+ "HttpOnly",
241
+ "SameSite=Lax",
242
+ `Max-Age=${seconds}`,
243
+ ];
244
+
245
+ // `__Host-` exige `Secure` y `Path=/` sin `Domain`: el navegador la
246
+ // rechaza sin aviso si falta. Se fuerza siempre.
247
+ if (secure || name.startsWith("__Host-")) attributes.push("Secure");
248
+
249
+ return attributes.join("; ");
250
+ }
251
+
252
+ /** El destino tras autenticarse solo puede ser una ruta de esta aplicación. */
253
+ function safeReturnTo(value: string | null): string {
254
+ if (!value) return "/";
255
+ if (!value.startsWith("/")) return "/";
256
+ if (value.startsWith("//")) return "/";
257
+ if (value.includes("\\")) return "/";
258
+
259
+ return value;
260
+ }
261
+
262
+ async function handleSignIn(request: Request): Promise<Response> {
263
+ const discovery = await discover(resolved.issuer);
264
+ const attempt = createAuthorizationAttempt();
265
+ const returnTo = safeReturnTo(new URL(request.url).searchParams.get("to"));
266
+
267
+ const target = authorizationUrl({
268
+ discovery,
269
+ clientId: resolved.clientId,
270
+ redirectUri: redirectUriFor(request),
271
+ scopes,
272
+ challenge: attempt.challenge,
273
+ state: attempt.state,
274
+ });
275
+
276
+ const secure = originOf(request).startsWith("https://");
277
+ const attemptValue = JSON.stringify({
278
+ verifier: attempt.verifier,
279
+ state: attempt.state,
280
+ returnTo,
281
+ });
282
+
283
+ return new Response(null, {
284
+ status: 302,
285
+ headers: {
286
+ location: target,
287
+ "set-cookie": cookieHeader(
288
+ attemptCookieName(attempt.state),
289
+ attemptValue,
290
+ ATTEMPT_MAX_AGE,
291
+ secure,
292
+ ),
293
+ },
294
+ });
295
+ }
296
+
297
+ async function handleCallback(request: Request): Promise<Response> {
298
+ const url = new URL(request.url);
299
+ const code = url.searchParams.get("code");
300
+ const state = url.searchParams.get("state");
301
+
302
+ // El `state` forma parte del nombre de la cookie: se valida su forma antes de usarlo.
303
+ const stateOk = state !== null && STATE_SHAPE.test(state);
304
+ const raw = stateOk
305
+ ? readCookie(request, attemptCookieName(state))
306
+ : undefined;
307
+ const secure = originOf(request).startsWith("https://");
308
+ const clearAttempt = stateOk
309
+ ? cookieHeader(attemptCookieName(state), "", 0, secure)
310
+ : null;
311
+
312
+ // La persona canceló o el portal rechazó la autorización (`access_denied`, etc.).
313
+ if (url.searchParams.get("error")) {
314
+ return new Response(
315
+ "El portal no autorizó el ingreso. Vuelve a intentarlo.",
316
+ {
317
+ status: 403,
318
+ headers: clearAttempt ? { "set-cookie": clearAttempt } : undefined,
319
+ },
320
+ );
321
+ }
322
+
323
+ /**
324
+ * La cookie del intento la escribió esta aplicación, pero viaja por el
325
+ * navegador: puede llegar truncada, editada o de una versión anterior del
326
+ * SDK. Sin este `try`, un `JSON.parse` sobre basura subía como error de
327
+ * servidor donde lo que corresponde es decir que el intento no vale.
328
+ */
329
+ let attempt: { verifier: string; state: string; returnTo: string } | null =
330
+ null;
331
+ try {
332
+ attempt = raw
333
+ ? (JSON.parse(raw) as {
334
+ verifier: string;
335
+ state: string;
336
+ returnTo: string;
337
+ })
338
+ : null;
339
+ } catch {
340
+ attempt = null;
341
+ }
342
+
343
+ /**
344
+ * Sin intento guardado, o con un `state` que no coincide, se aborta.
345
+ *
346
+ * Esa comprobación es lo que impide que un tercero fuerce a alguien a
347
+ * completar un flujo que empezó él: sin ella, un atacante puede hacer que
348
+ * la víctima acabe con la sesión del atacante iniciada en su navegador.
349
+ */
350
+ if (!code || !state || !attempt || !statesMatch(attempt.state, state)) {
351
+ return new Response("Petición de autenticación inválida o caducada.", {
352
+ status: 400,
353
+ });
354
+ }
355
+
356
+ let tokens: TokenSet;
357
+ let session: SSOSession<Permission>;
358
+ try {
359
+ const discovery = await discover(resolved.issuer);
360
+
361
+ tokens = await exchangeCode({
362
+ discovery,
363
+ clientId: resolved.clientId,
364
+ clientSecret: resolved.clientSecret,
365
+ code,
366
+ verifier: attempt.verifier,
367
+ redirectUri: redirectUriFor(request),
368
+ });
369
+
370
+ const profile = await fetchUserInfo({
371
+ discovery,
372
+ accessToken: tokens.accessToken,
373
+ });
374
+ session = buildSession<Permission>(profile, tokens);
375
+ } catch (error: unknown) {
376
+ // Código ya usado, caducado o portal caído: el intento no se puede reanudar, pero tampoco
377
+ // debe llegar al navegador un 500 con el detalle del portal.
378
+ console.error("[sso] el canje del código falló:", error);
379
+ return new Response(
380
+ "No se pudo completar el ingreso con el portal. Vuelve a intentarlo.",
381
+ {
382
+ status: 502,
383
+ headers: clearAttempt ? { "set-cookie": clearAttempt } : undefined,
384
+ },
385
+ );
386
+ }
387
+
388
+ const payload: SessionPayload = {
389
+ sub: session.user.id,
390
+ email: session.user.email,
391
+ name: session.user.name,
392
+ roles: session.roles,
393
+ permissions: session.permissions,
394
+ amr: session.amr,
395
+ accessToken: session.accessToken,
396
+ refreshToken: tokens.refreshToken,
397
+ // Se guarda porque `end-session` lo exige para cerrar la sesión del portal.
398
+ idToken: tokens.idToken,
399
+ expiresAt: session.expiresAt,
400
+ };
401
+
402
+ return new Response(null, {
403
+ status: 302,
404
+ headers: [
405
+ ["location", `${originOf(request)}${safeReturnTo(attempt.returnTo)}`],
406
+ [
407
+ "set-cookie",
408
+ cookieHeader(
409
+ cookieName,
410
+ await sealSession(payload, resolved.secret, maxAge),
411
+ maxAge,
412
+ secure,
413
+ ),
414
+ ],
415
+ // El intento ya se consumió: dejarlo vivo permitiría reutilizarlo.
416
+ ["set-cookie", cookieHeader(attemptCookieName(state), "", 0, secure)],
417
+ ],
418
+ });
419
+ }
420
+
421
+ /**
422
+ * Cierre de sesión en la aplicación **y** en el portal.
423
+ *
424
+ * Antes se redirigía a `end_session` sin parámetros, y el portal los exige:
425
+ * responde con un error de validación, así que la cookie local se borraba y
426
+ * la sesión del portal seguía viva. El efecto era el peor posible: el usuario
427
+ * creía haber salido y el siguiente «Entrar» lo dejaba dentro sin pedir nada.
428
+ *
429
+ * `id_token_hint` es lo que identifica qué sesión terminar, así que se lee de
430
+ * la cookie antes de borrarla. Sin él, `end-session` no tiene forma de saber
431
+ * de quién es la sesión y no se le puede pedir que la cierre.
432
+ */
433
+ async function handleSignOut(request: Request): Promise<Response> {
434
+ const origin = originOf(request);
435
+ const secure = origin.startsWith("https://");
436
+
437
+ const payload = await openSession(
438
+ readCookie(request, cookieName),
439
+ resolved.secret,
440
+ );
441
+
442
+ // Sin sesión local no hay nada que cerrar en el portal, así que ni se le
443
+ // pregunta: salir de una sesión que no existe no necesita una ida y vuelta.
444
+ let location = origin;
445
+
446
+ if (payload?.idToken) {
447
+ const discovery = await discover(resolved.issuer);
448
+
449
+ if (discovery.end_session_endpoint) {
450
+ const target = new URL(discovery.end_session_endpoint);
451
+ target.searchParams.set("id_token_hint", payload.idToken);
452
+ target.searchParams.set("client_id", resolved.clientId);
453
+
454
+ /**
455
+ * Solo se pide volver a la aplicación si se declaró una URI registrada.
456
+ * El portal rechaza cualquier destino que no lo esté, así que mandarlo
457
+ * a ciegas cambiaría una salida limpia por una pantalla de error.
458
+ */
459
+ if (resolved.postLogoutRedirectUri) {
460
+ target.searchParams.set(
461
+ "post_logout_redirect_uri",
462
+ resolved.postLogoutRedirectUri,
463
+ );
464
+ }
465
+
466
+ location = target.toString();
467
+ }
468
+ }
469
+
470
+ return new Response(null, {
471
+ status: 302,
472
+ headers: {
473
+ location,
474
+ "set-cookie": cookieHeader(cookieName, "", 0, secure),
475
+ },
476
+ });
477
+ }
478
+
479
+ /**
480
+ * Devuelve la sesión al día, hablando con el portal solo cuando hace falta.
481
+ *
482
+ * Dos cosas distintas pueden estar rancias:
483
+ *
484
+ * - El `access_token`, que dura quince minutos. Se renueva con el
485
+ * `refresh_token` sin molestar al usuario.
486
+ * - Los permisos, que son una foto del momento en que se autorizó. Se vuelven
487
+ * a pedir cuando se pasa `revalidate`, que es lo que hace que revocar un rol
488
+ * surta efecto de inmediato en lugar de esperar a que caduque la cookie.
489
+ *
490
+ * Si el portal rechaza la credencial —token revocado, sesión cerrada, cuenta
491
+ * bloqueada— se devuelve `null`: la sesión terminó. Un fallo de red o un error
492
+ * del servidor, en cambio, se propaga: son un mal momento, no una razón para
493
+ * cerrar la sesión de nadie.
494
+ */
495
+ async function resolveSession(
496
+ cookieValue: string | undefined,
497
+ resolution: { revalidate?: boolean } = {},
498
+ ): Promise<ResolvedSession<Permission> | null> {
499
+ const payload = await openSession(cookieValue, resolved.secret);
500
+ if (!payload) return null;
501
+
502
+ const expired = payload.expiresAt * 1000 <= Date.now();
503
+
504
+ // Camino rápido: ni caducó ni se pidió frescura, así que el descifrado local
505
+ // basta y no se añade una ida y vuelta a cada petición.
506
+ if (!expired && !resolution.revalidate) {
507
+ return { session: sessionFromPayload<Permission>(payload), cookie: null };
508
+ }
509
+
510
+ let accessToken = payload.accessToken;
511
+ let refreshToken = payload.refreshToken;
512
+ let idToken = payload.idToken;
513
+ let expiresAt = payload.expiresAt;
514
+
515
+ // Sin refresh token no queda nada que canjear: la sesión ya no sirve ni
516
+ // para llamar al portal. Se decide antes de pedir el descubrimiento, para
517
+ // no gastar una ida y vuelta en un caso ya resuelto.
518
+ if (expired && !refreshToken) return null;
519
+
520
+ const discovery = await discover(resolved.issuer);
521
+
522
+ if (expired && refreshToken) {
523
+ try {
524
+ const current = refreshToken;
525
+ const tokens = await refreshOnce(current, () =>
526
+ refreshTokens({
527
+ discovery,
528
+ clientId: resolved.clientId,
529
+ clientSecret: resolved.clientSecret,
530
+ refreshToken: current,
531
+ }),
532
+ );
533
+
534
+ accessToken = tokens.accessToken;
535
+ refreshToken = tokens.refreshToken ?? refreshToken;
536
+ idToken = tokens.idToken ?? idToken;
537
+ expiresAt = tokens.expiresAt;
538
+ } catch (error: unknown) {
539
+ if (isCredentialRejected(error)) return null;
540
+ if (warnIfStaleSession(payload.expiresAt)) return null;
541
+ throw error;
542
+ }
543
+ }
544
+
545
+ let profile: Record<string, unknown>;
546
+ try {
547
+ profile = await fetchUserInfo({ discovery, accessToken });
548
+ } catch (error: unknown) {
549
+ if (isCredentialRejected(error)) return null;
550
+ if (warnIfStaleSession(payload.expiresAt)) return null;
551
+ throw error;
552
+ }
553
+
554
+ const session = buildSession<Permission>(profile, {
555
+ accessToken,
556
+ expiresAt,
557
+ });
558
+
559
+ const next: SessionPayload = {
560
+ sub: session.user.id,
561
+ email: session.user.email,
562
+ name: session.user.name,
563
+ roles: session.roles,
564
+ permissions: session.permissions,
565
+ amr: session.amr,
566
+ accessToken,
567
+ refreshToken,
568
+ idToken,
569
+ expiresAt,
570
+ };
571
+
572
+ return {
573
+ session,
574
+ cookie: await sealSession(next, resolved.secret, maxAge),
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Renueva y vuelve a la aplicación.
580
+ *
581
+ * Es la única forma de persistir la renovación desde cualquier sitio: las
582
+ * pantallas de servidor no pueden escribir cookies, así que si el refresco
583
+ * ocurre ahí, el resultado se pierde al terminar la respuesta.
584
+ */
585
+ async function handleRefresh(request: Request): Promise<Response> {
586
+ const url = new URL(request.url);
587
+ const secure = originOf(request).startsWith("https://");
588
+ const returnTo = safeReturnTo(url.searchParams.get("to"));
589
+
590
+ let resolved: ResolvedSession<Permission> | null;
591
+ try {
592
+ resolved = await resolveSession(readCookie(request, cookieName), {
593
+ revalidate: true,
594
+ });
595
+ } catch {
596
+ // El portal no respondió. No se toca la cookie: la sesión sigue siendo la
597
+ // de antes y borrarla convertiría una caída en una sesión perdida.
598
+ return new Response("No se pudo comprobar la sesión con el portal.", {
599
+ status: 503,
600
+ });
601
+ }
602
+
603
+ const headers: Array<[string, string]> = [
604
+ ["location", `${originOf(request)}${returnTo}`],
605
+ ];
606
+
607
+ if (!resolved) {
608
+ headers.push(["set-cookie", cookieHeader(cookieName, "", 0, secure)]);
609
+ } else if (resolved.cookie) {
610
+ headers.push([
611
+ "set-cookie",
612
+ cookieHeader(cookieName, resolved.cookie, maxAge, secure),
613
+ ]);
614
+ }
615
+
616
+ return new Response(null, { status: 302, headers });
617
+ }
618
+
619
+ return {
620
+ basePath,
621
+ cookieName,
622
+ sessionMaxAge: maxAge,
623
+
624
+ signInPath(returnTo) {
625
+ return returnTo
626
+ ? `${basePath}/sign-in?to=${encodeURIComponent(returnTo)}`
627
+ : `${basePath}/sign-in`;
628
+ },
629
+
630
+ signOutPath() {
631
+ return `${basePath}/sign-out`;
632
+ },
633
+
634
+ async readSession(cookieValue) {
635
+ const payload = await openSession(cookieValue, resolved.secret);
636
+ if (!payload) return null;
637
+
638
+ return sessionFromPayload<Permission>(payload);
639
+ },
640
+
641
+ resolveSession,
642
+
643
+ async handleRequest(request) {
644
+ const { pathname } = new URL(request.url);
645
+ if (!pathname.startsWith(`${basePath}/`)) return null;
646
+
647
+ const route = pathname.slice(basePath.length + 1);
648
+ const handler =
649
+ route === "sign-in"
650
+ ? handleSignIn
651
+ : route === "callback"
652
+ ? handleCallback
653
+ : route === "sign-out"
654
+ ? handleSignOut
655
+ : route === "refresh"
656
+ ? handleRefresh
657
+ : null;
658
+ if (!handler) return null;
659
+
660
+ try {
661
+ return await handler(request);
662
+ } catch (error: unknown) {
663
+ // Portal caído o colgado: respuesta controlada, sin pila ni detalles hacia el navegador.
664
+ console.error(`[sso] ${route} falló:`, error);
665
+ return new Response("El portal SSO no responde. Intenta de nuevo.", {
666
+ status: 503,
667
+ });
668
+ }
669
+ },
670
+ };
671
+ }