@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dacyti-gsi/auth",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
package/src/config.ts CHANGED
@@ -1,212 +1,212 @@
1
- import { SSOConfigError } from "./errors";
2
- import type { SSOClientOptions } from "./types";
3
-
4
- /**
5
- * Lee una opción o su variable de entorno. Sin una de las dos no hay forma de
6
- * arrancar, y el error dice exactamente cuál falta y dónde ponerla.
7
- */
8
- function fromEnv(value: string | undefined, name: string): string {
9
- if (value) return value;
10
-
11
- const env = typeof process !== "undefined" ? process.env : undefined;
12
- const found = env?.[name];
13
- if (found) return found;
14
-
15
- throw new SSOConfigError(
16
- `Falta \`${name}\` en el entorno (o pasa \`${nameToOption(name)}\` al definir el cliente SSO).`,
17
- );
18
- }
19
-
20
- function nameToOption(name: string): string {
21
- switch (name) {
22
- case "SSO_ISSUER":
23
- return "issuer";
24
- case "SSO_CLIENT_ID":
25
- return "clientId";
26
- case "SSO_CLIENT_SECRET":
27
- return "clientSecret";
28
- case "SSO_SECRET":
29
- return "secret";
30
- default:
31
- return name;
32
- }
33
- }
34
-
35
- /**
36
- * Configuración con los cuatro valores de entorno ya resueltos.
37
- */
38
- export type ResolvedSSOOptions<Permission extends string = string> = Omit<
39
- SSOClientOptions<Permission>,
40
- "issuer" | "clientId" | "clientSecret" | "secret"
41
- > & {
42
- issuer: string;
43
- clientId: string;
44
- clientSecret: string;
45
- secret: string;
46
- };
47
-
48
- /**
49
- * Resuelve la configuración efectiva: lo explícito gana, lo demás sale del
50
- * entorno. Es lo que permite `defineNextSSO({ permissions })` sin nada más.
51
- */
52
- export function resolveOptions<Permission extends string>(
53
- options: SSOClientOptions<Permission>,
54
- ): ResolvedSSOOptions<Permission> {
55
- const env = typeof process !== "undefined" ? process.env : undefined;
56
-
57
- return {
58
- ...options,
59
- issuer: fromEnv(options.issuer, "SSO_ISSUER"),
60
- clientId: fromEnv(options.clientId, "SSO_CLIENT_ID"),
61
- clientSecret: fromEnv(options.clientSecret, "SSO_CLIENT_SECRET"),
62
- secret: fromEnv(options.secret, "SSO_SECRET"),
63
- postLogoutRedirectUri:
64
- options.postLogoutRedirectUri ?? env?.SSO_POST_LOGOUT_URI,
65
- };
66
- }
67
-
68
- function isLocalhost(url: URL): boolean {
69
- return (
70
- url.hostname === "localhost" ||
71
- url.hostname === "127.0.0.1" ||
72
- url.hostname === "[::1]" ||
73
- url.hostname.endsWith(".localhost")
74
- );
75
- }
76
-
77
- /** https, o http solo en localhost (incluye `*.localhost`, que resuelve a loopback). */
78
- function isSecureOrLocal(value: string): boolean {
79
- try {
80
- const url = new URL(value);
81
- return url.protocol === "https:" || (url.protocol === "http:" && isLocalhost(url));
82
- } catch {
83
- return false;
84
- }
85
- }
86
-
87
- function required(value: string | undefined, name: string): string {
88
- if (!value) {
89
- throw new SSOConfigError(
90
- `Falta \`${name}\` en la configuración del cliente SSO.`,
91
- );
92
- }
93
-
94
- return value;
95
- }
96
-
97
- /**
98
- * Valida la configuración al crear el cliente y falla rápido.
99
- *
100
- * Todo lo que antes era un 500 críptico en el primer login —issuer http,
101
- * secreto corto, catálogo con erratas— ahora es un error al arrancar, en el
102
- * archivo donde se declara el cliente.
103
- */
104
- export function validateOptions<Permission extends string>(
105
- options: SSOClientOptions<Permission>,
106
- ): asserts options is ResolvedSSOOptions<Permission> {
107
- const issuerValue = required(options.issuer, "issuer");
108
- const secretValue = required(options.secret, "secret");
109
- required(options.clientId, "clientId");
110
- required(options.clientSecret, "clientSecret");
111
-
112
- let issuer: URL;
113
- try {
114
- issuer = new URL(issuerValue);
115
- } catch {
116
- throw new SSOConfigError(`\`issuer\` no es una URL válida: ${issuerValue}`);
117
- }
118
- if (
119
- issuer.protocol !== "https:" &&
120
- !(issuer.protocol === "http:" && isLocalhost(issuer))
121
- ) {
122
- throw new SSOConfigError(
123
- `\`issuer\` debe ser https (o http solo en localhost). Recibido: ${issuerValue}`,
124
- );
125
- }
126
-
127
- if (secretValue.length < 32) {
128
- throw new SSOConfigError(
129
- "`secret` necesita al menos 32 caracteres. Genera uno con:\n" +
130
- " node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"",
131
- );
132
- }
133
-
134
- if (options.appUrl !== undefined) {
135
- let app: URL;
136
- try {
137
- app = new URL(options.appUrl);
138
- } catch {
139
- throw new SSOConfigError(
140
- `\`appUrl\` no es una URL válida: ${options.appUrl}`,
141
- );
142
- }
143
- if (
144
- app.protocol !== "https:" &&
145
- !(app.protocol === "http:" && isLocalhost(app))
146
- ) {
147
- throw new SSOConfigError(
148
- "`appUrl` debe ser https (o http solo en localhost).",
149
- );
150
- }
151
- }
152
-
153
- if (
154
- options.postLogoutRedirectUri !== undefined &&
155
- !isSecureOrLocal(options.postLogoutRedirectUri)
156
- ) {
157
- throw new SSOConfigError(
158
- "`postLogoutRedirectUri` debe ser https (o http solo en localhost) y estar registrada en el portal.",
159
- );
160
- }
161
-
162
- if (options.basePath !== undefined && !options.basePath.startsWith("/")) {
163
- throw new SSOConfigError(
164
- "`basePath` debe empezar con `/` (p. ej. `/api/sso`).",
165
- );
166
- }
167
-
168
- if (options.cookieName !== undefined) {
169
- if (options.cookieName === "" || /[;,\s]/.test(options.cookieName)) {
170
- throw new SSOConfigError(
171
- "`cookieName` no puede contener `;`, `,` ni espacios.",
172
- );
173
- }
174
- if (
175
- options.cookieName.startsWith("__Host-") &&
176
- (options.appUrl ?? "").startsWith("http://")
177
- ) {
178
- throw new SSOConfigError("Una cookie `__Host-` exige origen https.");
179
- }
180
- }
181
-
182
- if (options.sessionMaxAge !== undefined && options.sessionMaxAge <= 0) {
183
- throw new SSOConfigError(
184
- "`sessionMaxAge` debe ser mayor que cero (segundos).",
185
- );
186
- }
187
-
188
- if (options.permissions !== undefined) {
189
- const seen = new Set<string>();
190
- for (const permission of options.permissions) {
191
- /**
192
- * Solo cordura estructural: cadenas no vacías y sin repetir. La FORMA del
193
- * permiso no se valida a propósito —el portal acepta cualquier cadena
194
- * opaca y solo él define el catálogo—. Rechazar aquí `admin` o
195
- * `acceso.total` porque no parecen `recurso:acción` convertiría una
196
- * convención recomendada en una regla que el portal no tiene, y una app
197
- * válida dejaría de arrancar.
198
- */
199
- if (typeof permission !== "string" || permission.length === 0) {
200
- throw new SSOConfigError(
201
- "El catálogo `permissions` solo admite cadenas no vacías.",
202
- );
203
- }
204
- if (seen.has(permission)) {
205
- throw new SSOConfigError(
206
- `Permiso duplicado en el catálogo: "${permission}".`,
207
- );
208
- }
209
- seen.add(permission);
210
- }
211
- }
212
- }
1
+ import { SSOConfigError } from "./errors";
2
+ import type { SSOClientOptions } from "./types";
3
+
4
+ /**
5
+ * Lee una opción o su variable de entorno. Sin una de las dos no hay forma de
6
+ * arrancar, y el error dice exactamente cuál falta y dónde ponerla.
7
+ */
8
+ function fromEnv(value: string | undefined, name: string): string {
9
+ if (value) return value;
10
+
11
+ const env = typeof process !== "undefined" ? process.env : undefined;
12
+ const found = env?.[name];
13
+ if (found) return found;
14
+
15
+ throw new SSOConfigError(
16
+ `Falta \`${name}\` en el entorno (o pasa \`${nameToOption(name)}\` al definir el cliente SSO).`,
17
+ );
18
+ }
19
+
20
+ function nameToOption(name: string): string {
21
+ switch (name) {
22
+ case "SSO_ISSUER":
23
+ return "issuer";
24
+ case "SSO_CLIENT_ID":
25
+ return "clientId";
26
+ case "SSO_CLIENT_SECRET":
27
+ return "clientSecret";
28
+ case "SSO_SECRET":
29
+ return "secret";
30
+ default:
31
+ return name;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Configuración con los cuatro valores de entorno ya resueltos.
37
+ */
38
+ export type ResolvedSSOOptions<Permission extends string = string> = Omit<
39
+ SSOClientOptions<Permission>,
40
+ "issuer" | "clientId" | "clientSecret" | "secret"
41
+ > & {
42
+ issuer: string;
43
+ clientId: string;
44
+ clientSecret: string;
45
+ secret: string;
46
+ };
47
+
48
+ /**
49
+ * Resuelve la configuración efectiva: lo explícito gana, lo demás sale del
50
+ * entorno. Es lo que permite `defineNextSSO({ permissions })` sin nada más.
51
+ */
52
+ export function resolveOptions<Permission extends string>(
53
+ options: SSOClientOptions<Permission>,
54
+ ): ResolvedSSOOptions<Permission> {
55
+ const env = typeof process !== "undefined" ? process.env : undefined;
56
+
57
+ return {
58
+ ...options,
59
+ issuer: fromEnv(options.issuer, "SSO_ISSUER"),
60
+ clientId: fromEnv(options.clientId, "SSO_CLIENT_ID"),
61
+ clientSecret: fromEnv(options.clientSecret, "SSO_CLIENT_SECRET"),
62
+ secret: fromEnv(options.secret, "SSO_SECRET"),
63
+ postLogoutRedirectUri:
64
+ options.postLogoutRedirectUri ?? env?.SSO_POST_LOGOUT_URI,
65
+ };
66
+ }
67
+
68
+ function isLocalhost(url: URL): boolean {
69
+ return (
70
+ url.hostname === "localhost" ||
71
+ url.hostname === "127.0.0.1" ||
72
+ url.hostname === "[::1]" ||
73
+ url.hostname.endsWith(".localhost")
74
+ );
75
+ }
76
+
77
+ /** https, o http solo en localhost (incluye `*.localhost`, que resuelve a loopback). */
78
+ function isSecureOrLocal(value: string): boolean {
79
+ try {
80
+ const url = new URL(value);
81
+ return url.protocol === "https:" || (url.protocol === "http:" && isLocalhost(url));
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function required(value: string | undefined, name: string): string {
88
+ if (!value) {
89
+ throw new SSOConfigError(
90
+ `Falta \`${name}\` en la configuración del cliente SSO.`,
91
+ );
92
+ }
93
+
94
+ return value;
95
+ }
96
+
97
+ /**
98
+ * Valida la configuración al crear el cliente y falla rápido.
99
+ *
100
+ * Todo lo que antes era un 500 críptico en el primer login —issuer http,
101
+ * secreto corto, catálogo con erratas— ahora es un error al arrancar, en el
102
+ * archivo donde se declara el cliente.
103
+ */
104
+ export function validateOptions<Permission extends string>(
105
+ options: SSOClientOptions<Permission>,
106
+ ): asserts options is ResolvedSSOOptions<Permission> {
107
+ const issuerValue = required(options.issuer, "issuer");
108
+ const secretValue = required(options.secret, "secret");
109
+ required(options.clientId, "clientId");
110
+ required(options.clientSecret, "clientSecret");
111
+
112
+ let issuer: URL;
113
+ try {
114
+ issuer = new URL(issuerValue);
115
+ } catch {
116
+ throw new SSOConfigError(`\`issuer\` no es una URL válida: ${issuerValue}`);
117
+ }
118
+ if (
119
+ issuer.protocol !== "https:" &&
120
+ !(issuer.protocol === "http:" && isLocalhost(issuer))
121
+ ) {
122
+ throw new SSOConfigError(
123
+ `\`issuer\` debe ser https (o http solo en localhost). Recibido: ${issuerValue}`,
124
+ );
125
+ }
126
+
127
+ if (secretValue.length < 32) {
128
+ throw new SSOConfigError(
129
+ "`secret` necesita al menos 32 caracteres. Genera uno con:\n" +
130
+ " node -e \"console.log(require('crypto').randomBytes(32).toString('base64url'))\"",
131
+ );
132
+ }
133
+
134
+ if (options.appUrl !== undefined) {
135
+ let app: URL;
136
+ try {
137
+ app = new URL(options.appUrl);
138
+ } catch {
139
+ throw new SSOConfigError(
140
+ `\`appUrl\` no es una URL válida: ${options.appUrl}`,
141
+ );
142
+ }
143
+ if (
144
+ app.protocol !== "https:" &&
145
+ !(app.protocol === "http:" && isLocalhost(app))
146
+ ) {
147
+ throw new SSOConfigError(
148
+ "`appUrl` debe ser https (o http solo en localhost).",
149
+ );
150
+ }
151
+ }
152
+
153
+ if (
154
+ options.postLogoutRedirectUri !== undefined &&
155
+ !isSecureOrLocal(options.postLogoutRedirectUri)
156
+ ) {
157
+ throw new SSOConfigError(
158
+ "`postLogoutRedirectUri` debe ser https (o http solo en localhost) y estar registrada en el portal.",
159
+ );
160
+ }
161
+
162
+ if (options.basePath !== undefined && !options.basePath.startsWith("/")) {
163
+ throw new SSOConfigError(
164
+ "`basePath` debe empezar con `/` (p. ej. `/api/sso`).",
165
+ );
166
+ }
167
+
168
+ if (options.cookieName !== undefined) {
169
+ if (options.cookieName === "" || /[;,\s]/.test(options.cookieName)) {
170
+ throw new SSOConfigError(
171
+ "`cookieName` no puede contener `;`, `,` ni espacios.",
172
+ );
173
+ }
174
+ if (
175
+ options.cookieName.startsWith("__Host-") &&
176
+ (options.appUrl ?? "").startsWith("http://")
177
+ ) {
178
+ throw new SSOConfigError("Una cookie `__Host-` exige origen https.");
179
+ }
180
+ }
181
+
182
+ if (options.sessionMaxAge !== undefined && options.sessionMaxAge <= 0) {
183
+ throw new SSOConfigError(
184
+ "`sessionMaxAge` debe ser mayor que cero (segundos).",
185
+ );
186
+ }
187
+
188
+ if (options.permissions !== undefined) {
189
+ const seen = new Set<string>();
190
+ for (const permission of options.permissions) {
191
+ /**
192
+ * Solo cordura estructural: cadenas no vacías y sin repetir. La FORMA del
193
+ * permiso no se valida a propósito —el portal acepta cualquier cadena
194
+ * opaca y solo él define el catálogo—. Rechazar aquí `admin` o
195
+ * `acceso.total` porque no parecen `recurso:acción` convertiría una
196
+ * convención recomendada en una regla que el portal no tiene, y una app
197
+ * válida dejaría de arrancar.
198
+ */
199
+ if (typeof permission !== "string" || permission.length === 0) {
200
+ throw new SSOConfigError(
201
+ "El catálogo `permissions` solo admite cadenas no vacías.",
202
+ );
203
+ }
204
+ if (seen.has(permission)) {
205
+ throw new SSOConfigError(
206
+ `Permiso duplicado en el catálogo: "${permission}".`,
207
+ );
208
+ }
209
+ seen.add(permission);
210
+ }
211
+ }
212
+ }
package/src/core.ts CHANGED
@@ -8,7 +8,10 @@ import type { DiscoveryDocument, SSOSession } from "./types";
8
8
  const PORTAL_TIMEOUT_MS = 10_000;
9
9
 
10
10
  function portalFetch(url: string, init: RequestInit = {}): Promise<Response> {
11
- return fetch(url, { ...init, signal: AbortSignal.timeout(PORTAL_TIMEOUT_MS) });
11
+ return fetch(url, {
12
+ ...init,
13
+ signal: AbortSignal.timeout(PORTAL_TIMEOUT_MS),
14
+ });
12
15
  }
13
16
 
14
17
  /** Cachea el documento de descubrimiento por emisor. */
@@ -104,9 +107,7 @@ export function buildSession<Permission extends string>(
104
107
  user: {
105
108
  id: String(profile.sub),
106
109
  email: String(profile.email ?? ""),
107
- emailVerified: profile.email_verified === true,
108
110
  name: typeof profile.name === "string" ? profile.name : null,
109
- image: typeof profile.picture === "string" ? profile.picture : null,
110
111
  },
111
112
  roles,
112
113
  permissions,
@@ -129,9 +130,7 @@ export function buildSession<Permission extends string>(
129
130
  export function sessionFromPayload<Permission extends string>(payload: {
130
131
  sub: string;
131
132
  email: string;
132
- emailVerified: boolean;
133
133
  name: string | null;
134
- image: string | null;
135
134
  roles: string[];
136
135
  permissions: string[];
137
136
  amr: string[];
@@ -145,9 +144,7 @@ export function sessionFromPayload<Permission extends string>(payload: {
145
144
  user: {
146
145
  id: payload.sub,
147
146
  email: payload.email,
148
- emailVerified: payload.emailVerified,
149
147
  name: payload.name,
150
- image: payload.image,
151
148
  },
152
149
  roles: payload.roles,
153
150
  permissions,