@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/LICENSE +202 -0
- package/README.md +419 -3
- package/package.json +71 -4
- package/src/config.ts +212 -0
- package/src/core.ts +451 -0
- package/src/errors.ts +53 -0
- package/src/index.ts +673 -0
- package/src/next.ts +296 -0
- package/src/pkce.ts +48 -0
- package/src/policy.ts +102 -0
- package/src/react.tsx +171 -0
- package/src/session-cookie.ts +86 -0
- package/src/types.ts +143 -0
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
|
+
}
|