@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/package.json +1 -1
- package/src/config.ts +212 -212
- package/src/core.ts +4 -7
- package/src/index.ts +671 -673
- package/src/next.ts +19 -6
- package/src/policy.ts +102 -102
- package/src/react.tsx +171 -171
- package/src/session-cookie.ts +1 -3
- package/src/types.ts +0 -2
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
const
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
if (
|
|
610
|
-
headers.push([
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
+
}
|