@dacyti-gsi/auth 0.3.0 → 0.5.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/README.md CHANGED
@@ -88,6 +88,48 @@ El portal solo redirige a URIs registradas, así que sin ella la sesión se cier
88
88
  igual pero el navegador se queda en el portal. Si la registras, indícala también
89
89
  en `postLogoutRedirectUri`.
90
90
 
91
+ ## Declarar permisos y roles desde el código (`sso.sync()`)
92
+
93
+ El catálogo de la app vive en su código y se declara solo en el portal, sin copiarlo
94
+ a mano a la consola:
95
+
96
+ ```ts
97
+ // lib/sso.ts
98
+ export const sso = defineNextSSO({
99
+ permissions: ["pedidos:ver", "pedidos:anular"] as const,
100
+ roles: {
101
+ gerente: ["pedidos:ver", "pedidos:anular"],
102
+ lector: ["pedidos:ver"],
103
+ },
104
+ // Lo que la consola muestra al decidir a quién dar cada cosa.
105
+ descriptions: {
106
+ permissions: { "pedidos:anular": "Anular pedidos ya confirmados" },
107
+ roles: { gerente: "Gestiona los pedidos de su sucursal" },
108
+ },
109
+ });
110
+ ```
111
+
112
+ ```ts
113
+ // instrumentation.ts
114
+ export async function register() {
115
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
116
+ const { sso } = await import("@/lib/sso");
117
+ await sso.sync().catch((error) => console.warn("[sso] sin sincronizar:", error));
118
+ }
119
+ ```
120
+
121
+ - Los permisos de cada rol salen del tipo de `permissions`, y las claves de
122
+ `descriptions` también: una errata no compila.
123
+ - Es idempotente y solo toca el catálogo de este `clientId` (autentica con las mismas
124
+ credenciales del cliente, `client_secret_basic`). Se puede llamar en cada arranque.
125
+ - Declara el catálogo; **no asigna roles a cuentas**, eso se decide en la consola.
126
+ - No borra: un permiso o rol que quitas del código sigue en la consola hasta que
127
+ alguien lo elimina allí.
128
+ - Todo o nada: si un rol pide un permiso que no está en el catálogo, el portal rechaza
129
+ el manifiesto entero y `sync()` lanza con el motivo.
130
+ - Un rol que incluya `sso:asignar-roles` permite a su titular asignar roles de esta
131
+ app desde su cuenta, sin ser administrador del portal.
132
+
91
133
  ## Renovar el acceso (`proxy.ts`)
92
134
 
93
135
  El acceso dura 15 minutos. Los Server Components no pueden escribir cookies, así que
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dacyti-gsi/auth",
3
- "version": "0.3.0",
3
+ "version": "0.5.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.0",
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 url.protocol === "https:" || (url.protocol === "http:" && isLocalhost(url));
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,64 @@ 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
+ descriptions?: {
464
+ permissions?: Readonly<Record<string, string | undefined>>;
465
+ roles?: Readonly<Record<string, string | undefined>>;
466
+ };
467
+ }): Promise<{ permissions: number; roles: number }> {
468
+ const endpoint = new URL("/api/apps/manifest", input.issuer);
469
+ const credentials = Buffer.from(
470
+ `${input.clientId}:${input.clientSecret}`,
471
+ ).toString("base64");
472
+
473
+ const response = await portalFetch(endpoint.toString(), {
474
+ method: "POST",
475
+ headers: {
476
+ authorization: `Basic ${credentials}`,
477
+ "content-type": "application/json",
478
+ },
479
+ body: JSON.stringify({
480
+ permissions: input.permissions.map((key) => ({
481
+ key,
482
+ description: input.descriptions?.permissions?.[key],
483
+ })),
484
+ roles: Object.entries(input.roles).map(([name, permissions]) => ({
485
+ name,
486
+ permissions,
487
+ description: input.descriptions?.roles?.[name],
488
+ })),
489
+ }),
490
+ });
491
+
492
+ const payload = (await response.json().catch(() => ({}))) as Record<
493
+ string,
494
+ unknown
495
+ >;
496
+
497
+ if (!response.ok) {
498
+ throw new SSOProtocolError(
499
+ `El portal rechazó el manifiesto (HTTP ${response.status}): ${String(payload.message ?? payload.error ?? "sin detalle")}`,
500
+ payload,
501
+ response.status,
502
+ );
503
+ }
504
+
505
+ return {
506
+ permissions: Number(payload.permissions ?? 0),
507
+ roles: Number(payload.roles ?? 0),
508
+ };
509
+ }
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,16 @@ 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
+ descriptions: resolved.descriptions,
662
+ }),
663
+
643
664
  async handleRequest(request) {
644
665
  const { pathname } = new URL(request.url);
645
666
  if (!pathname.startsWith(`${basePath}/`)) return null;
package/src/types.ts CHANGED
@@ -47,6 +47,30 @@ 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
+
61
+ /**
62
+ * Descripciones legibles de permisos y roles, para la consola del portal.
63
+ *
64
+ * `sso.sync()` las envía junto al manifiesto; sin ellas la consola solo muestra
65
+ * claves como `pedidos:anular`, que no dicen qué concede cada una a quien debe
66
+ * decidir a quién dárselo. Las claves de `permissions` salen del mismo tipo que
67
+ * `permissions`: una descripción para un permiso que no existe no compila.
68
+ */
69
+ descriptions?: {
70
+ permissions?: Partial<Record<Permission, string>>;
71
+ roles?: Readonly<Record<string, string>>;
72
+ };
73
+
50
74
  /** Ruta donde se montan los handlers. Por defecto `/api/sso`. */
51
75
  basePath?: string;
52
76
 
@@ -139,3 +163,6 @@ export type DiscoveryDocument = {
139
163
  revocation_endpoint?: string;
140
164
  introspection_endpoint?: string;
141
165
  };
166
+
167
+ /** Lo que el portal respondió al sincronizar el manifiesto. */
168
+ export type SyncResult = { permissions: number; roles: number };