@dacyti-gsi/auth 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core.ts ADDED
@@ -0,0 +1,451 @@
1
+ import { SSOForbiddenError, SSOProtocolError } from "./errors";
2
+ import type { DiscoveryDocument, SSOSession } from "./types";
3
+
4
+ /**
5
+ * Tope de espera por llamada al portal. Sin él, un portal colgado (no caído: colgado) deja
6
+ * pendiente cada render que consulte la sesión, y la app entera se congela con él.
7
+ */
8
+ const PORTAL_TIMEOUT_MS = 10_000;
9
+
10
+ function portalFetch(url: string, init: RequestInit = {}): Promise<Response> {
11
+ return fetch(url, { ...init, signal: AbortSignal.timeout(PORTAL_TIMEOUT_MS) });
12
+ }
13
+
14
+ /** Cachea el documento de descubrimiento por emisor. */
15
+ const discoveryCache = new Map<string, Promise<DiscoveryDocument>>();
16
+
17
+ /**
18
+ * Lee el documento de descubrimiento del emisor.
19
+ *
20
+ * Se cachea en memoria: es prácticamente inmutable y pedirlo en cada login
21
+ * añadiría una ida y vuelta a cada autenticación. El caché muere con el
22
+ * proceso, así que un despliegue basta para refrescarlo.
23
+ */
24
+ export function discover(issuer: string): Promise<DiscoveryDocument> {
25
+ const cached = discoveryCache.get(issuer);
26
+ if (cached) return cached;
27
+
28
+ const pending = (async () => {
29
+ const url = `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
30
+ const response = await portalFetch(url);
31
+
32
+ if (!response.ok) {
33
+ throw new SSOProtocolError(
34
+ `No se pudo leer el descubrimiento OIDC en ${url} (HTTP ${response.status}). ` +
35
+ "Comprueba que `issuer` apunta al portal y termina en /api/auth.",
36
+ undefined,
37
+ response.status,
38
+ );
39
+ }
40
+
41
+ return (await response.json()) as DiscoveryDocument;
42
+ })();
43
+
44
+ // Un fallo no debe quedar cacheado: el siguiente intento tiene que reintentar.
45
+ pending.catch(() => discoveryCache.delete(issuer));
46
+ discoveryCache.set(issuer, pending);
47
+
48
+ return pending;
49
+ }
50
+
51
+ /** Solo para pruebas: vacía el caché de descubrimiento y de renovaciones. */
52
+ export function clearDiscoveryCache(): void {
53
+ discoveryCache.clear();
54
+ refreshes.clear();
55
+ }
56
+
57
+ /**
58
+ * Extrae un claim del portal buscándolo por sufijo.
59
+ *
60
+ * Los claims propios llegan bajo un espacio de nombres URI
61
+ * (`https://sso.ejemplo.com/claims/perms`), que depende del dominio donde esté
62
+ * desplegado el portal. Buscarlos por sufijo evita tener que configurar ese
63
+ * dominio en cada aplicación, y sigue funcionando si el portal se mueve.
64
+ */
65
+ function claimBySuffix<T>(
66
+ source: Record<string, unknown>,
67
+ suffix: string,
68
+ ): T | undefined {
69
+ for (const [key, value] of Object.entries(source)) {
70
+ if (key.endsWith(`/claims/${suffix}`)) return value as T;
71
+ }
72
+
73
+ return undefined;
74
+ }
75
+
76
+ function asStringArray(value: unknown): string[] {
77
+ return Array.isArray(value)
78
+ ? value.filter((entry) => typeof entry === "string")
79
+ : [];
80
+ }
81
+
82
+ export type UserInfo = Record<string, unknown>;
83
+
84
+ /**
85
+ * Construye la sesión a partir de la respuesta de `/userinfo`.
86
+ *
87
+ * Los permisos vienen de ahí y no del token: el `id_token` responde a quién es
88
+ * la persona, no a qué puede hacer. El portal, además, solo puede acotar los
89
+ * permisos a la aplicación correcta en ese endpoint.
90
+ */
91
+ export function buildSession<Permission extends string>(
92
+ profile: UserInfo,
93
+ tokens: { accessToken: string; expiresAt: number },
94
+ ): SSOSession<Permission> {
95
+ const permissions = asStringArray(
96
+ claimBySuffix(profile, "perms"),
97
+ ) as Permission[];
98
+ const roles = asStringArray(claimBySuffix(profile, "roles"));
99
+ const amr = asStringArray(profile.amr);
100
+
101
+ const has = (permission: Permission) => permissions.includes(permission);
102
+
103
+ return {
104
+ user: {
105
+ id: String(profile.sub),
106
+ email: String(profile.email ?? ""),
107
+ emailVerified: profile.email_verified === true,
108
+ name: typeof profile.name === "string" ? profile.name : null,
109
+ image: typeof profile.picture === "string" ? profile.picture : null,
110
+ },
111
+ roles,
112
+ permissions,
113
+ amr,
114
+ accessToken: tokens.accessToken,
115
+ expiresAt: tokens.expiresAt,
116
+ isExpired: () => tokens.expiresAt * 1000 <= Date.now(),
117
+
118
+ can: has,
119
+ canAll: (...wanted: Permission[]) => wanted.every(has),
120
+ canAny: (...wanted: Permission[]) => wanted.some(has),
121
+ requires(permission: Permission) {
122
+ if (!has(permission)) throw new SSOForbiddenError(permission);
123
+ },
124
+ usedSecondFactor: () => amr.includes("mfa"),
125
+ };
126
+ }
127
+
128
+ /** Reconstruye la sesión desde la cookie, sin volver a llamar al portal. */
129
+ export function sessionFromPayload<Permission extends string>(payload: {
130
+ sub: string;
131
+ email: string;
132
+ emailVerified: boolean;
133
+ name: string | null;
134
+ image: string | null;
135
+ roles: string[];
136
+ permissions: string[];
137
+ amr: string[];
138
+ accessToken: string;
139
+ expiresAt: number;
140
+ }): SSOSession<Permission> {
141
+ const permissions = payload.permissions as Permission[];
142
+ const has = (permission: Permission) => permissions.includes(permission);
143
+
144
+ return {
145
+ user: {
146
+ id: payload.sub,
147
+ email: payload.email,
148
+ emailVerified: payload.emailVerified,
149
+ name: payload.name,
150
+ image: payload.image,
151
+ },
152
+ roles: payload.roles,
153
+ permissions,
154
+ amr: payload.amr,
155
+ accessToken: payload.accessToken,
156
+ expiresAt: payload.expiresAt,
157
+ isExpired: () => payload.expiresAt * 1000 <= Date.now(),
158
+
159
+ can: has,
160
+ canAll: (...wanted: Permission[]) => wanted.every(has),
161
+ canAny: (...wanted: Permission[]) => wanted.some(has),
162
+ requires(permission: Permission) {
163
+ if (!has(permission)) throw new SSOForbiddenError(permission);
164
+ },
165
+ usedSecondFactor: () => payload.amr.includes("mfa"),
166
+ };
167
+ }
168
+
169
+ export type TokenSet = {
170
+ accessToken: string;
171
+ refreshToken?: string;
172
+ idToken?: string;
173
+ expiresAt: number;
174
+ };
175
+
176
+ /** Canjea el código de autorización por tokens. */
177
+ export async function exchangeCode(input: {
178
+ discovery: DiscoveryDocument;
179
+ clientId: string;
180
+ clientSecret: string;
181
+ code: string;
182
+ verifier: string;
183
+ redirectUri: string;
184
+ }): Promise<TokenSet> {
185
+ const response = await portalFetch(input.discovery.token_endpoint, {
186
+ method: "POST",
187
+ headers: { "content-type": "application/x-www-form-urlencoded" },
188
+ body: new URLSearchParams({
189
+ grant_type: "authorization_code",
190
+ code: input.code,
191
+ redirect_uri: input.redirectUri,
192
+ client_id: input.clientId,
193
+ client_secret: input.clientSecret,
194
+ code_verifier: input.verifier,
195
+ }),
196
+ });
197
+
198
+ const body = (await response.json()) as Record<string, unknown>;
199
+
200
+ if (!response.ok) {
201
+ /**
202
+ * `invalid_scope` casi siempre es lo mismo: la aplicación se registró sin
203
+ * `offline_access` y el SDK lo pide por defecto para poder renovar. Sin esta
204
+ * pista, el mensaje que ve quien integra es un 400 sin explicación.
205
+ */
206
+ const hint =
207
+ body.error === "invalid_scope"
208
+ ? " El cliente no tiene permitido alguno de los scopes solicitados: registra " +
209
+ "`offline_access` en el portal, o quítalo de `scopes` al crear el cliente SSO."
210
+ : "";
211
+
212
+ throw new SSOProtocolError(
213
+ `El portal rechazó el canje del código (HTTP ${response.status}).${hint}`,
214
+ body,
215
+ response.status,
216
+ );
217
+ }
218
+
219
+ return {
220
+ accessToken: String(body.access_token),
221
+ refreshToken:
222
+ typeof body.refresh_token === "string" ? body.refresh_token : undefined,
223
+ idToken: typeof body.id_token === "string" ? body.id_token : undefined,
224
+ expiresAt:
225
+ typeof body.expires_at === "number"
226
+ ? body.expires_at
227
+ : Math.floor(Date.now() / 1000) + Number(body.expires_in ?? 900),
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Cambia el `refresh_token` por un access token nuevo.
233
+ *
234
+ * Sin esto, la sesión de la aplicación muere a los quince minutos, que es lo
235
+ * que dura el access token, aunque la cookie siga viva durante días: cualquier
236
+ * llamada a una API con `session.accessToken` empezaría a responder 401 sin que
237
+ * nadie hubiera cerrado sesión.
238
+ *
239
+ * Requiere que la autorización se pidiera con el scope `offline_access`, que el
240
+ * SDK incluye por defecto. Si el portal no emitió refresh token, no hay nada
241
+ * que renovar y el token caducado es el final de la sesión.
242
+ */
243
+ export async function refreshTokens(input: {
244
+ discovery: DiscoveryDocument;
245
+ clientId: string;
246
+ clientSecret: string;
247
+ refreshToken: string;
248
+ }): Promise<TokenSet> {
249
+ const response = await portalFetch(input.discovery.token_endpoint, {
250
+ method: "POST",
251
+ headers: { "content-type": "application/x-www-form-urlencoded" },
252
+ body: new URLSearchParams({
253
+ grant_type: "refresh_token",
254
+ refresh_token: input.refreshToken,
255
+ client_id: input.clientId,
256
+ client_secret: input.clientSecret,
257
+ }),
258
+ });
259
+
260
+ const body = (await response.json().catch(() => ({}))) as Record<
261
+ string,
262
+ unknown
263
+ >;
264
+
265
+ if (!response.ok) {
266
+ throw new SSOProtocolError(
267
+ `El portal rechazó la renovación del acceso (HTTP ${response.status}).`,
268
+ body,
269
+ response.status,
270
+ );
271
+ }
272
+
273
+ return {
274
+ accessToken: String(body.access_token),
275
+ refreshToken:
276
+ typeof body.refresh_token === "string" ? body.refresh_token : undefined,
277
+ idToken: typeof body.id_token === "string" ? body.id_token : undefined,
278
+ expiresAt:
279
+ typeof body.expires_at === "number"
280
+ ? body.expires_at
281
+ : Math.floor(Date.now() / 1000) + Number(body.expires_in ?? 900),
282
+ };
283
+ }
284
+
285
+ /** Pide el perfil y los permisos. */
286
+ export async function fetchUserInfo(input: {
287
+ discovery: DiscoveryDocument;
288
+ accessToken: string;
289
+ }): Promise<UserInfo> {
290
+ const response = await portalFetch(input.discovery.userinfo_endpoint, {
291
+ headers: { authorization: `Bearer ${input.accessToken}` },
292
+ });
293
+
294
+ if (!response.ok) {
295
+ throw new SSOProtocolError(
296
+ `El portal rechazó la petición de userinfo (HTTP ${response.status}).`,
297
+ undefined,
298
+ response.status,
299
+ );
300
+ }
301
+
302
+ return (await response.json()) as UserInfo;
303
+ }
304
+
305
+ /** Construye la URL de autorización. */
306
+ export function authorizationUrl(input: {
307
+ discovery: DiscoveryDocument;
308
+ clientId: string;
309
+ redirectUri: string;
310
+ scopes: string[];
311
+ challenge: string;
312
+ state: string;
313
+ }): string {
314
+ const url = new URL(input.discovery.authorization_endpoint);
315
+
316
+ url.searchParams.set("client_id", input.clientId);
317
+ url.searchParams.set("redirect_uri", input.redirectUri);
318
+ url.searchParams.set("response_type", "code");
319
+ url.searchParams.set("scope", input.scopes.join(" "));
320
+ url.searchParams.set("state", input.state);
321
+ url.searchParams.set("code_challenge", input.challenge);
322
+ url.searchParams.set("code_challenge_method", "S256");
323
+
324
+ return url.toString();
325
+ }
326
+
327
+ /**
328
+ * Pide un token máquina a máquina (`client_credentials`).
329
+ *
330
+ * Sin usuario detrás: no hay `id_token` ni `refresh_token`, y los scopes de
331
+ * identidad (`openid`, `profile`, `email`) se rechazan. Autentica con
332
+ * `client_secret_post`, igual que el canje de código del SDK.
333
+ */
334
+ export async function clientCredentialsToken(input: {
335
+ discovery: DiscoveryDocument;
336
+ clientId: string;
337
+ clientSecret: string;
338
+ scopes?: string[];
339
+ }): Promise<{ accessToken: string; expiresAt: number; scope: string }> {
340
+ const body = new URLSearchParams({
341
+ grant_type: "client_credentials",
342
+ client_id: input.clientId,
343
+ client_secret: input.clientSecret,
344
+ });
345
+ if (input.scopes?.length) body.set("scope", input.scopes.join(" "));
346
+
347
+ const response = await portalFetch(input.discovery.token_endpoint, {
348
+ method: "POST",
349
+ headers: { "content-type": "application/x-www-form-urlencoded" },
350
+ body,
351
+ });
352
+
353
+ const payload = (await response.json().catch(() => ({}))) as Record<
354
+ string,
355
+ unknown
356
+ >;
357
+
358
+ if (!response.ok) {
359
+ throw new SSOProtocolError(
360
+ `El portal rechazó las credenciales de cliente (HTTP ${response.status}).`,
361
+ payload,
362
+ response.status,
363
+ );
364
+ }
365
+
366
+ return {
367
+ accessToken: String(payload.access_token),
368
+ expiresAt:
369
+ typeof payload.expires_at === "number"
370
+ ? payload.expires_at
371
+ : Math.floor(Date.now() / 1000) + Number(payload.expires_in ?? 3600),
372
+ scope: typeof payload.scope === "string" ? payload.scope : "",
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Introspección RFC 7662: pregunta al portal si un token sigue vivo.
378
+ *
379
+ * Solo para servidores de recursos confidenciales. Para autorizar a una
380
+ * persona, `/userinfo` sigue siendo la respuesta: la introspección no conoce
381
+ * la sesión ni los permisos por aplicación.
382
+ */
383
+ export async function introspectToken(input: {
384
+ discovery: DiscoveryDocument;
385
+ clientId: string;
386
+ clientSecret: string;
387
+ token: string;
388
+ }): Promise<Record<string, unknown>> {
389
+ const endpoint = (input.discovery as Record<string, unknown>)
390
+ .introspection_endpoint as string | undefined;
391
+ if (!endpoint) {
392
+ throw new SSOProtocolError(
393
+ "El descubrimiento no publica `introspection_endpoint`.",
394
+ );
395
+ }
396
+
397
+ const response = await portalFetch(endpoint, {
398
+ method: "POST",
399
+ headers: { "content-type": "application/x-www-form-urlencoded" },
400
+ body: new URLSearchParams({
401
+ client_id: input.clientId,
402
+ client_secret: input.clientSecret,
403
+ token: input.token,
404
+ }),
405
+ });
406
+
407
+ const raw = await response.text();
408
+ let body: Record<string, unknown> = {};
409
+ try {
410
+ body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
411
+ } catch {
412
+ body = { crudo: raw.slice(0, 200) };
413
+ }
414
+
415
+ if (!response.ok) {
416
+ throw new SSOProtocolError(
417
+ `El portal rechazó la introspección (HTTP ${response.status}).`,
418
+ body,
419
+ response.status,
420
+ );
421
+ }
422
+
423
+ return body;
424
+ }
425
+
426
+ /**
427
+ * Renovaciones en curso o recién hechas, por refresh token. Con rotación, el portal solo acepta un
428
+ * canje por token: dos renders en paralelo (layout + página) que canjearan el mismo token harían
429
+ * fallar al segundo y cerrarían la sesión de alguien que no hizo nada. Se conserva el resultado un
430
+ * minuto para los renders que lleguen justo después con la cookie vieja (los Server Components no
431
+ * pueden escribir la nueva; ver `sso.proxy()`).
432
+ */
433
+ const REFRESH_REUSE_MS = 60_000;
434
+ const refreshes = new Map<string, Promise<TokenSet>>();
435
+
436
+ export function refreshOnce(
437
+ token: string,
438
+ run: () => Promise<TokenSet>,
439
+ ): Promise<TokenSet> {
440
+ const known = refreshes.get(token);
441
+ if (known) return known;
442
+
443
+ const pending = run();
444
+ refreshes.set(token, pending);
445
+ pending.then(
446
+ () => setTimeout(() => refreshes.delete(token), REFRESH_REUSE_MS).unref?.(),
447
+ () => refreshes.delete(token),
448
+ );
449
+
450
+ return pending;
451
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,53 @@
1
+ /** Base de todos los errores del cliente, para poder capturarlos juntos. */
2
+ export class SSOError extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = new.target.name;
6
+ }
7
+ }
8
+
9
+ /** La configuración no permite arrancar. Se lanza al construir el cliente. */
10
+ export class SSOConfigError extends SSOError {}
11
+
12
+ /**
13
+ * Falta un permiso. La lanza `session.requires()`.
14
+ *
15
+ * Es una clase propia para que una aplicación pueda distinguirla de cualquier
16
+ * otro fallo y responder con un 403 en lugar de con un 500.
17
+ */
18
+ export class SSOForbiddenError extends SSOError {
19
+ constructor(readonly permission: string) {
20
+ super(`Falta el permiso "${permission}"`);
21
+ }
22
+ }
23
+
24
+ /** El portal respondió con un error, o la respuesta no era la esperada. */
25
+ export class SSOProtocolError extends SSOError {
26
+ constructor(
27
+ message: string,
28
+ readonly detail?: unknown,
29
+ /**
30
+ * Código HTTP de la respuesta, cuando lo hubo.
31
+ *
32
+ * Existe para poder separar dos fracasos que se parecen y no se tratan
33
+ * igual: un `401` significa que la credencial ya no vale y que la sesión
34
+ * terminó, mientras que un `502` de un proxy es un mal momento y no debe
35
+ * costarle la sesión a nadie.
36
+ */
37
+ readonly status?: number,
38
+ ) {
39
+ super(message);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * La credencial ya no sirve: el portal respondió `401`/`403`/`400` a un token
45
+ * que se creía válido. La sesión se da por terminada.
46
+ */
47
+ export function isCredentialRejected(error: unknown): boolean {
48
+ return (
49
+ error instanceof SSOProtocolError &&
50
+ typeof error.status === "number" &&
51
+ (error.status === 400 || error.status === 401 || error.status === 403)
52
+ );
53
+ }