@dacyti-gsi/auth 0.3.0 → 0.4.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 +2 -2
- package/src/config.ts +4 -1
- package/src/core.ts +53 -0
- package/src/index.ts +21 -1
- package/src/types.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dacyti-gsi/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Cliente del SSO de la división: OIDC (PKCE), sesión y permisos para Next.js/React",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"sso",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@dacyti-gsi/config": "^0.1.0",
|
|
66
66
|
"@types/node": "^20.19.43",
|
|
67
67
|
"@types/react": "^19",
|
|
68
|
-
"next": "16.4.
|
|
68
|
+
"next": "16.4.2",
|
|
69
69
|
"react": "19.3.0",
|
|
70
70
|
"typescript": "^5",
|
|
71
71
|
"vitest": "^4.1.10"
|
package/src/config.ts
CHANGED
|
@@ -78,7 +78,10 @@ function isLocalhost(url: URL): boolean {
|
|
|
78
78
|
function isSecureOrLocal(value: string): boolean {
|
|
79
79
|
try {
|
|
80
80
|
const url = new URL(value);
|
|
81
|
-
return
|
|
81
|
+
return (
|
|
82
|
+
url.protocol === "https:" ||
|
|
83
|
+
(url.protocol === "http:" && isLocalhost(url))
|
|
84
|
+
);
|
|
82
85
|
} catch {
|
|
83
86
|
return false;
|
|
84
87
|
}
|
package/src/core.ts
CHANGED
|
@@ -446,3 +446,56 @@ export function refreshOnce(
|
|
|
446
446
|
|
|
447
447
|
return pending;
|
|
448
448
|
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Envía el manifiesto (permisos y roles) de la aplicación al portal.
|
|
452
|
+
*
|
|
453
|
+
* Autentica con `client_secret_basic`, las mismas credenciales con las que ya se
|
|
454
|
+
* canjean los códigos. El portal lo aplica de forma idempotente y solo sobre el
|
|
455
|
+
* catálogo de este `clientId`, así que se puede llamar en cada arranque.
|
|
456
|
+
*/
|
|
457
|
+
export async function syncManifest(input: {
|
|
458
|
+
issuer: string;
|
|
459
|
+
clientId: string;
|
|
460
|
+
clientSecret: string;
|
|
461
|
+
permissions: readonly string[];
|
|
462
|
+
roles: Readonly<Record<string, readonly string[]>>;
|
|
463
|
+
}): Promise<{ permissions: number; roles: number }> {
|
|
464
|
+
const endpoint = new URL("/api/apps/manifest", input.issuer);
|
|
465
|
+
const credentials = Buffer.from(
|
|
466
|
+
`${input.clientId}:${input.clientSecret}`,
|
|
467
|
+
).toString("base64");
|
|
468
|
+
|
|
469
|
+
const response = await portalFetch(endpoint.toString(), {
|
|
470
|
+
method: "POST",
|
|
471
|
+
headers: {
|
|
472
|
+
authorization: `Basic ${credentials}`,
|
|
473
|
+
"content-type": "application/json",
|
|
474
|
+
},
|
|
475
|
+
body: JSON.stringify({
|
|
476
|
+
permissions: input.permissions.map((key) => ({ key })),
|
|
477
|
+
roles: Object.entries(input.roles).map(([name, permissions]) => ({
|
|
478
|
+
name,
|
|
479
|
+
permissions,
|
|
480
|
+
})),
|
|
481
|
+
}),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const payload = (await response.json().catch(() => ({}))) as Record<
|
|
485
|
+
string,
|
|
486
|
+
unknown
|
|
487
|
+
>;
|
|
488
|
+
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
throw new SSOProtocolError(
|
|
491
|
+
`El portal rechazó el manifiesto (HTTP ${response.status}): ${String(payload.message ?? payload.error ?? "sin detalle")}`,
|
|
492
|
+
payload,
|
|
493
|
+
response.status,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
permissions: Number(payload.permissions ?? 0),
|
|
499
|
+
roles: Number(payload.roles ?? 0),
|
|
500
|
+
};
|
|
501
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
refreshOnce,
|
|
9
9
|
refreshTokens,
|
|
10
10
|
sessionFromPayload,
|
|
11
|
+
syncManifest,
|
|
11
12
|
type TokenSet,
|
|
12
13
|
} from "./core";
|
|
13
14
|
import { isCredentialRejected, SSOError } from "./errors";
|
|
@@ -17,7 +18,7 @@ import {
|
|
|
17
18
|
type SessionPayload,
|
|
18
19
|
sealSession,
|
|
19
20
|
} from "./session-cookie";
|
|
20
|
-
import type { SSOClientOptions, SSOSession } from "./types";
|
|
21
|
+
import type { SSOClientOptions, SSOSession, SyncResult } from "./types";
|
|
21
22
|
|
|
22
23
|
export {
|
|
23
24
|
type ResolvedSSOOptions,
|
|
@@ -46,6 +47,7 @@ export type {
|
|
|
46
47
|
SSOClientOptions,
|
|
47
48
|
SSOSession,
|
|
48
49
|
SSOUser,
|
|
50
|
+
SyncResult,
|
|
49
51
|
} from "./types";
|
|
50
52
|
|
|
51
53
|
/**
|
|
@@ -150,6 +152,15 @@ export type SSOClient<Permission extends string = string> = {
|
|
|
150
152
|
|
|
151
153
|
/** Atiende las rutas del cliente. Devuelve `null` si la ruta no le compete. */
|
|
152
154
|
handleRequest(request: Request): Promise<Response | null>;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Declara en el portal los `permissions` y `roles` de esta aplicación.
|
|
158
|
+
*
|
|
159
|
+
* Es idempotente y solo toca el catálogo de este cliente: llámalo en cada
|
|
160
|
+
* arranque (p. ej. desde `instrumentation.ts`) y la consola refleja siempre lo
|
|
161
|
+
* que el código exige. No asigna roles a cuentas.
|
|
162
|
+
*/
|
|
163
|
+
sync(): Promise<SyncResult>;
|
|
153
164
|
};
|
|
154
165
|
|
|
155
166
|
/**
|
|
@@ -640,6 +651,15 @@ export function defineSSO<const Permission extends string = string>(
|
|
|
640
651
|
|
|
641
652
|
resolveSession,
|
|
642
653
|
|
|
654
|
+
sync: () =>
|
|
655
|
+
syncManifest({
|
|
656
|
+
issuer: resolved.issuer,
|
|
657
|
+
clientId: resolved.clientId,
|
|
658
|
+
clientSecret: resolved.clientSecret,
|
|
659
|
+
permissions: resolved.permissions ?? [],
|
|
660
|
+
roles: resolved.roles ?? {},
|
|
661
|
+
}),
|
|
662
|
+
|
|
643
663
|
async handleRequest(request) {
|
|
644
664
|
const { pathname } = new URL(request.url);
|
|
645
665
|
if (!pathname.startsWith(`${basePath}/`)) return null;
|
package/src/types.ts
CHANGED
|
@@ -47,6 +47,17 @@ export type SSOClientOptions<Permission extends string = string> = {
|
|
|
47
47
|
*/
|
|
48
48
|
permissions?: readonly Permission[];
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Roles de la aplicación y los permisos que agrupa cada uno.
|
|
52
|
+
*
|
|
53
|
+
* Junto con `permissions`, es el manifiesto que `sso.sync()` envía al portal:
|
|
54
|
+
* declarar aquí evita copiarlos a mano en la consola. Los permisos de cada
|
|
55
|
+
* rol salen del mismo tipo que `permissions`, así que una errata no compila.
|
|
56
|
+
* Sincronizar solo declara el catálogo; a quién se asigna cada rol se sigue
|
|
57
|
+
* decidiendo en la consola.
|
|
58
|
+
*/
|
|
59
|
+
roles?: Readonly<Record<string, readonly Permission[]>>;
|
|
60
|
+
|
|
50
61
|
/** Ruta donde se montan los handlers. Por defecto `/api/sso`. */
|
|
51
62
|
basePath?: string;
|
|
52
63
|
|
|
@@ -139,3 +150,6 @@ export type DiscoveryDocument = {
|
|
|
139
150
|
revocation_endpoint?: string;
|
|
140
151
|
introspection_endpoint?: string;
|
|
141
152
|
};
|
|
153
|
+
|
|
154
|
+
/** Lo que el portal respondió al sincronizar el manifiesto. */
|
|
155
|
+
export type SyncResult = { permissions: number; roles: number };
|