@bigso/auth-sdk 0.3.0 → 0.4.2

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 ADDED
@@ -0,0 +1,188 @@
1
+ # @bigso/auth-sdk
2
+
3
+ SDK oficial de autenticación para Bigso SSO, compatible con el estándar SSO v2.3. Este paquete permite integrar aplicaciones web con el flujo de autenticación basado en iframe seguro, utilizando PKCE, JWS firmados y comunicación mediante postMessage.
4
+
5
+ ## 🚀 Características
6
+
7
+ ✅ **Flujo seguro** con PKCE obligatorio y JWS firmado.
8
+
9
+ ✅ **Compatible con SSO v2.3** (whitelist de origins, kid en JWS, validación de nonce, timeout reactivo).
10
+
11
+ ✅ **Manejo automático de estados** (state, nonce, verifier, requestId).
12
+
13
+ ✅ **Verificación de firma JWS** en el frontend usando `jose` y JWKS remoto.
14
+
15
+ ✅ **Timeout reactivo configurable**, activado tras `sso-ready`.
16
+
17
+ ✅ **Soporte para redirect_uri y tenant_hint**.
18
+
19
+ ✅ **Manejo de errores** incluyendo `version_mismatch` con fallback automático.
20
+
21
+ ✅ **API asíncrona** basada en promesas.
22
+
23
+ ✅ **Sistema de eventos** (ready, success, error, fallback, debug).
24
+
25
+ ✅ **Ligero** (~6 kB) y con tipos TypeScript.
26
+
27
+ ## 📦 Instalación
28
+
29
+ ```bash
30
+ npm install @bigso/auth-sdk
31
+ # o
32
+ yarn add @bigso/auth-sdk
33
+ # o
34
+ pnpm add @bigso/auth-sdk
35
+ ```
36
+
37
+ ## 🧪 Uso básico
38
+
39
+ ```typescript
40
+ import { BigsoAuth } from '@bigso/auth-sdk';
41
+
42
+ const auth = new BigsoAuth({
43
+ clientId: 'tu-client-id',
44
+ ssoOrigin: 'https://sso.tudominio.com',
45
+ jwksUrl: 'https://sso.tudominio.com/.well-known/jwks.json',
46
+ timeout: 5000, // opcional, por defecto 5000ms
47
+ debug: true, // opcional, logs de depuración
48
+ redirectUri: 'https://miapp.com/callback', // opcional
49
+ tenantHint: 'mi-tenant' // opcional
50
+ });
51
+
52
+ auth.on('ready', () => console.log('✅ Iframe listo'));
53
+ auth.on('success', (payload) => {
54
+ console.log('✅ Autenticación exitosa', payload);
55
+ // Envía el signed_payload al backend para canjear el código
56
+ enviarAlBackend(payload.signed_payload);
57
+ });
58
+ auth.on('error', (error) => console.error('❌ Error:', error));
59
+ auth.on('fallback', () => console.log('⚠️ Fallback por redirección activado'));
60
+
61
+ auth.login().catch(err => console.error('Login falló', err));
62
+ ```
63
+
64
+ ## 📚 API Reference
65
+
66
+ ### `new BigsoAuth(options)`
67
+ Crea una nueva instancia del cliente de autenticación.
68
+
69
+ #### Opciones de configuración
70
+ | Parámetro | Tipo | Obligatorio | Por defecto | Descripción |
71
+ | :--- | :--- | :---: | :---: | :--- |
72
+ | `clientId` | `string` | ✅ | — | Client ID registrado en el SSO. |
73
+ | `ssoOrigin` | `string` | ✅ | — | Origen del SSO (ej. `https://sso.bigso.co`). |
74
+ | `jwksUrl` | `string` | ✅ | — | URL del JWKS para verificar firmas (ej. `/.well-known/jwks.json`). |
75
+ | `timeout` | `number` | ❌ | `5000` | Tiempo máximo de espera tras `sso-ready` (milisegundos). |
76
+ | `debug` | `boolean` | ❌ | `false` | Activa logs de depuración en consola. |
77
+ | `redirectUri` | `string` | ❌ | `''` | URI de redirección registrada (se valida exactamente en el SSO). |
78
+ | `tenantHint` | `string` | ❌ | `''` | Sugerencia de tenant para flujos multi-tenant. |
79
+
80
+ ### `auth.login()`
81
+ Inicia el flujo de autenticación. Devuelve una promesa que se resuelve con el payload decodificado del JWS (solo para información; la validación final debe realizarse en el backend).
82
+
83
+ **Retorna**: `Promise<any>` – Payload del JWS (contiene `code`, `state`, `nonce`, `iss`, etc.).
84
+
85
+ **Rechaza**: `Error` o payload de error del iframe.
86
+
87
+ ### `auth.abort()`
88
+ Cancela el flujo de autenticación en curso, eliminando el iframe y rechazando la promesa.
89
+
90
+ ### `auth.on(event, handler)`
91
+ Registra un manejador para los eventos del SDK.
92
+
93
+ #### Eventos disponibles:
94
+ | Evento | Descripción | Parámetros |
95
+ | :--- | :--- | :--- |
96
+ | `ready` | Se emite cuando el iframe está listo y se ha enviado `sso-init`. | — |
97
+ | `success` | Se emite tras verificar exitosamente la firma JWS y validar `state`/`nonce` en el frontend. | `payload: any` (payload del JWS) |
98
+ | `error` | Se emite cuando ocurre un error (incluyendo `version_mismatch` antes del fallback automático). | `error: Error | SsoErrorPayload` |
99
+ | `fallback` | Se emite justo antes de redirigir a la URL de fallback (por timeout o `version_mismatch`). | — |
100
+ | `debug` | Se emite cuando `debug: true` para logs internos. | `args: any[]` |
101
+
102
+ ## ⚙️ Ejemplos avanzados
103
+
104
+ ### Personalizar el timeout
105
+ ```typescript
106
+ const auth = new BigsoAuth({
107
+ clientId: 'abc123',
108
+ ssoOrigin: 'https://sso.bigso.co',
109
+ jwksUrl: 'https://sso.bigso.co/.well-known/jwks.json',
110
+ timeout: 10000 // 10 segundos
111
+ });
112
+ ```
113
+
114
+ ### Usar `redirect_uri` y `tenant_hint`
115
+ ```typescript
116
+ const auth = new BigsoAuth({
117
+ clientId: 'abc123',
118
+ ssoOrigin: 'https://sso.bigso.co',
119
+ jwksUrl: 'https://sso.bigso.co/.well-known/jwks.json',
120
+ redirectUri: 'https://admin.miapp.com/callback',
121
+ tenantHint: 'enterprise'
122
+ });
123
+ ```
124
+
125
+ ### Manejo de fallback personalizado
126
+ Puedes escuchar el evento `fallback` para ejecutar tu propia lógica antes de la redirección automática:
127
+
128
+ ```typescript
129
+ auth.on('fallback', () => {
130
+ console.log('Mostrando spinner o mensaje...');
131
+ // Por defecto, el SDK redirige a /authorize.
132
+ // Si quieres evitar la redirección automática, puedes sobrescribir el comportamiento
133
+ // pero no es recomendable ya que es el mecanismo de último recurso.
134
+ });
135
+ ```
136
+
137
+ ## 🔒 Consideraciones de seguridad
138
+
139
+ - **Whitelist de origins**: El SDK asume que el SSO Core está configurado correctamente con la whitelist de origins para cada `client_id` y que responde con `Content-Security-Policy: frame-ancestors`. El SDK no puede controlar esto; es responsabilidad del administrador del SSO.
140
+ - **Validación en backend**: La verificación del JWS en el frontend es solo una capa adicional de integridad. El backend debe validar la firma, el nonce, el state y canjear el código usando PKCE antes de emitir tokens.
141
+ - **nonce y state**: El SDK genera valores aleatorios seguros (`crypto.randomUUID`) y los valida en el frontend. El backend debe realizar la misma validación para prevenir ataques de replay.
142
+ - **Prevención de replay**: El SDK no incluye lógica de deduplicación de JWS en el frontend; esto debe implementarse en el backend usando `jti` si es necesario.
143
+ - **Timeout**: El timeout se inicia solo después de `sso-ready`, evitando falsos positivos. Si se alcanza, el SDK ejecuta un fallback a redirección (endpoint `/authorize`).
144
+ - **Versiones**: El SDK usa `v: '2.3'`. Si el iframe responde con `version_mismatch`, se activa el fallback automático.
145
+
146
+ ## 🛠️ Desarrollo
147
+
148
+ ### Construcción
149
+ ```bash
150
+ npm run build # genera dist/ con formatos ESM, CJS y types
151
+ ```
152
+
153
+ ### Pruebas
154
+ ```bash
155
+ npm test # ejecuta vitest
156
+ ```
157
+
158
+ ### Linting
159
+ ```bash
160
+ npm run lint
161
+ ```
162
+
163
+ ## 📝 Changelog
164
+
165
+ ### v0.4.0 (2026-03-23)
166
+ Protocolo actualizado a SSO v2.3
167
+ - Mensaje `sso-init` con `v: '2.3'`.
168
+ - Timeout reactivo (se inicia tras `sso-ready`).
169
+ - Validación de `requestId` en respuestas.
170
+ - Soporte para `redirect_uri`, `tenant_hint`, `timeout_ms`.
171
+ - Manejo de error `version_mismatch` con fallback automático.
172
+ - Validación de `nonce` en el frontend tras verificar JWS.
173
+
174
+ **Mejoras internas:**
175
+ - Método `abort()` para cancelar operación.
176
+ - Evento `debug` opcional.
177
+ - Documentación completa.
178
+
179
+ **Breaking changes:** Ninguno, pero se recomienda actualizar el backend para validar `nonce` si no lo hacía antes.
180
+
181
+ ### v0.2.0 (anterior)
182
+ - Implementación inicial con v2.2.
183
+
184
+ ## 📄 Licencia
185
+ MIT © Bigso
186
+
187
+ ## 🤝 Contribuciones
188
+ Por favor, abre un issue o pull request en el repositorio oficial.
package/dist/index.cjs ADDED
@@ -0,0 +1,371 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ BigsoAuth: () => BigsoAuth
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/utils/crypto.ts
28
+ async function sha256Base64Url(input) {
29
+ const encoder = new TextEncoder();
30
+ const data = encoder.encode(input);
31
+ const digest = await crypto.subtle.digest("SHA-256", data);
32
+ return base64Url(new Uint8Array(digest));
33
+ }
34
+ function generateVerifier(length = 32) {
35
+ const array = new Uint8Array(length);
36
+ crypto.getRandomValues(array);
37
+ return base64Url(array);
38
+ }
39
+ function base64Url(bytes) {
40
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
41
+ }
42
+ function generateRandomId() {
43
+ return crypto.randomUUID();
44
+ }
45
+
46
+ // src/utils/events.ts
47
+ var EventEmitter = class {
48
+ constructor() {
49
+ this.events = {};
50
+ }
51
+ on(event, handler) {
52
+ if (!this.events[event]) this.events[event] = [];
53
+ this.events[event].push(handler);
54
+ }
55
+ off(event, handler) {
56
+ if (!this.events[event]) return;
57
+ this.events[event] = this.events[event].filter((h) => h !== handler);
58
+ }
59
+ emit(event, data) {
60
+ this.events[event]?.forEach((fn) => fn(data));
61
+ }
62
+ };
63
+
64
+ // src/utils/jws.ts
65
+ var import_jose = require("jose");
66
+ async function verifySignedPayload(token, jwksUrl, expectedAudience) {
67
+ const JWKS = (0, import_jose.createRemoteJWKSet)(new URL(jwksUrl));
68
+ const { payload } = await (0, import_jose.jwtVerify)(token, JWKS, {
69
+ audience: expectedAudience
70
+ });
71
+ return payload;
72
+ }
73
+
74
+ // src/core/auth.ts
75
+ var BigsoAuth = class extends EventEmitter {
76
+ constructor(options) {
77
+ super();
78
+ this.authCompleted = false;
79
+ this.requestId = generateRandomId();
80
+ this.loginInProgress = false;
81
+ this.options = {
82
+ timeout: 5e3,
83
+ // por defecto 5s (estándar v2.3)
84
+ debug: false,
85
+ redirectUri: "",
86
+ tenantHint: "",
87
+ theme: "light",
88
+ ...options
89
+ };
90
+ }
91
+ /**
92
+ * Inicia el flujo de autenticación.
93
+ * @returns Promise que resuelve con el payload decodificado del JWS (solo para información; el backend debe validar)
94
+ */
95
+ async login() {
96
+ if (this.loginInProgress) {
97
+ this.debug("login() ya en curso, ignorando llamada duplicada");
98
+ return Promise.reject(new Error("Login already in progress"));
99
+ }
100
+ this.loginInProgress = true;
101
+ this.authCompleted = false;
102
+ const state = generateRandomId();
103
+ const nonce = generateRandomId();
104
+ const verifier = generateVerifier();
105
+ const requestId = this.requestId;
106
+ sessionStorage.setItem("sso_ctx", JSON.stringify({ state, nonce, verifier, requestId }));
107
+ this.createUI();
108
+ return new Promise((resolve, reject) => {
109
+ this.abortController = new AbortController();
110
+ const { signal } = this.abortController;
111
+ const cleanup = () => {
112
+ if (this.timeoutId) clearTimeout(this.timeoutId);
113
+ if (this.messageListener) window.removeEventListener("message", this.messageListener);
114
+ this.iframe?.remove();
115
+ this.iframe = void 0;
116
+ this.authCompleted = true;
117
+ this.loginInProgress = false;
118
+ };
119
+ this.messageListener = async (event) => {
120
+ if (event.origin !== this.options.ssoOrigin) {
121
+ this.debug("Ignorado mensaje de origen no autorizado:", event.origin);
122
+ return;
123
+ }
124
+ const msg = event.data;
125
+ this.debug("Mensaje recibido:", msg);
126
+ if (msg.requestId && msg.requestId !== requestId) {
127
+ this.debug("requestId no coincide, ignorado");
128
+ return;
129
+ }
130
+ if (msg.type === "sso-ready") {
131
+ this.debug("sso-ready recibido, iniciando timeout y enviando sso-init");
132
+ this.timeoutId = window.setTimeout(() => {
133
+ if (!this.authCompleted) {
134
+ this.debug("Timeout alcanzado, activando fallback");
135
+ this.closeUI();
136
+ cleanup();
137
+ this.emit("fallback");
138
+ window.location.href = this.buildFallbackUrl();
139
+ reject(new Error("Timeout"));
140
+ }
141
+ }, this.options.timeout);
142
+ const codeChallenge = await sha256Base64Url(verifier);
143
+ const initPayload = {
144
+ state,
145
+ nonce,
146
+ code_challenge: codeChallenge,
147
+ code_challenge_method: "S256",
148
+ origin: window.location.origin,
149
+ ...this.options.redirectUri && { redirect_uri: this.options.redirectUri },
150
+ ...this.options.tenantHint && { tenant_hint: this.options.tenantHint },
151
+ timeout_ms: this.options.timeout
152
+ // pasar el timeout configurado (opcional)
153
+ };
154
+ this.iframe?.contentWindow?.postMessage({
155
+ v: "2.3",
156
+ // versión del protocolo (estándar v2.3)
157
+ source: "@app/widget",
158
+ type: "sso-init",
159
+ requestId: this.requestId,
160
+ payload: initPayload
161
+ }, this.options.ssoOrigin);
162
+ this.emit("ready");
163
+ return;
164
+ }
165
+ if (msg.type === "sso-success") {
166
+ this.debug("sso-success recibido");
167
+ clearTimeout(this.timeoutId);
168
+ try {
169
+ const payload = msg.payload;
170
+ const ctx = JSON.parse(sessionStorage.getItem("sso_ctx") || "{}");
171
+ if (payload.state !== ctx.state) {
172
+ throw new Error("Invalid state");
173
+ }
174
+ const decoded = await verifySignedPayload(
175
+ payload.signed_payload,
176
+ this.options.jwksUrl,
177
+ window.location.origin
178
+ // aud esperado
179
+ );
180
+ if (decoded.nonce !== ctx.nonce) {
181
+ throw new Error("Invalid nonce");
182
+ }
183
+ this.debug("JWS v\xE1lido, payload:", decoded);
184
+ this.closeUI();
185
+ cleanup();
186
+ this.emit("success", decoded);
187
+ resolve(decoded);
188
+ } catch (err) {
189
+ this.debug("Error en sso-success:", err);
190
+ this.closeUI();
191
+ cleanup();
192
+ this.emit("error", err);
193
+ reject(err);
194
+ }
195
+ return;
196
+ }
197
+ if (msg.type === "sso-error") {
198
+ const errorPayload = msg.payload;
199
+ this.debug("sso-error recibido:", errorPayload);
200
+ clearTimeout(this.timeoutId);
201
+ this.closeUI();
202
+ cleanup();
203
+ if (errorPayload.code === "version_mismatch") {
204
+ this.emit("error", errorPayload);
205
+ window.location.href = this.buildFallbackUrl();
206
+ reject(new Error(`Version mismatch: expected ${errorPayload.expected_version}`));
207
+ } else {
208
+ this.emit("error", errorPayload);
209
+ reject(errorPayload);
210
+ }
211
+ }
212
+ if (msg.type === "sso-close") {
213
+ this.debug("sso-close recibido");
214
+ this.closeUI();
215
+ cleanup();
216
+ reject(new Error("Login cancelled by user"));
217
+ }
218
+ };
219
+ window.addEventListener("message", this.messageListener);
220
+ signal.addEventListener("abort", () => {
221
+ this.debug("Operaci\xF3n abortada");
222
+ this.closeUI();
223
+ cleanup();
224
+ reject(new Error("Login aborted"));
225
+ });
226
+ });
227
+ }
228
+ /** Cancela el flujo de autenticación en curso */
229
+ abort() {
230
+ this.abortController?.abort();
231
+ }
232
+ // ─── UI Management ───────────────────────────────────────────────
233
+ /**
234
+ * Crea (o reutiliza) el overlay con Shadow DOM y el iframe visible.
235
+ * Patrón tomado del CDN widget v1: Shadow DOM para aislar estilos.
236
+ */
237
+ createUI() {
238
+ if (!this.hostEl) {
239
+ this.hostEl = document.createElement("div");
240
+ this.hostEl.id = "bigso-auth-host";
241
+ this.shadowRoot = this.hostEl.attachShadow({ mode: "open" });
242
+ const style = document.createElement("style");
243
+ style.textContent = this.getOverlayStyles();
244
+ this.shadowRoot.appendChild(style);
245
+ this.overlayEl = document.createElement("div");
246
+ this.overlayEl.className = "sso-overlay";
247
+ const closeBtn = document.createElement("button");
248
+ closeBtn.className = "sso-close-btn";
249
+ closeBtn.innerHTML = "&times;";
250
+ closeBtn.setAttribute("aria-label", "Cerrar modal");
251
+ closeBtn.addEventListener("click", () => this.abort());
252
+ this.overlayEl.appendChild(closeBtn);
253
+ this.overlayEl.addEventListener("click", (event) => {
254
+ if (event.target === this.overlayEl) {
255
+ this.abort();
256
+ }
257
+ });
258
+ this.shadowRoot.appendChild(this.overlayEl);
259
+ document.body.appendChild(this.hostEl);
260
+ }
261
+ this.iframe = document.createElement("iframe");
262
+ this.iframe.className = "sso-frame";
263
+ this.iframe.src = `${this.options.ssoOrigin}/auth/sign-in?v=2.3&client_id=${this.options.clientId}&embedded=true&app_id=${this.options.clientId}&theme=${this.options.theme}`;
264
+ this.iframe.setAttribute("title", "SSO Login");
265
+ this.overlayEl.appendChild(this.iframe);
266
+ this.debug("Iframe creado", this.iframe.src);
267
+ this.overlayEl.classList.remove("sso-closing");
268
+ this.overlayEl.style.display = "flex";
269
+ }
270
+ /**
271
+ * Cierra el overlay con animación suave (fadeOut + slideDown).
272
+ * El overlay persiste en el DOM (solo se oculta).
273
+ */
274
+ closeUI() {
275
+ if (!this.overlayEl || this.overlayEl.style.display === "none") return;
276
+ this.overlayEl.classList.add("sso-closing");
277
+ setTimeout(() => {
278
+ if (this.overlayEl) {
279
+ this.overlayEl.style.display = "none";
280
+ this.overlayEl.classList.remove("sso-closing");
281
+ }
282
+ }, 200);
283
+ }
284
+ /**
285
+ * Estilos CSS encapsulados dentro del Shadow DOM.
286
+ * Migrados del widget CDN v1 con las mismas animaciones y responsive.
287
+ */
288
+ getOverlayStyles() {
289
+ return `
290
+ .sso-overlay {
291
+ position: fixed;
292
+ inset: 0;
293
+ display: none;
294
+ justify-content: center;
295
+ align-items: center;
296
+ background: rgba(0, 0, 0, 0.6);
297
+ z-index: 999999;
298
+ backdrop-filter: blur(4px);
299
+ -webkit-backdrop-filter: blur(4px);
300
+ animation: fadeIn 0.2s ease;
301
+ }
302
+ .sso-frame {
303
+ width: 370px;
304
+ height: 350px;
305
+ border: none;
306
+ border-radius: 16px;
307
+ background: var(--card-bg, #fff);
308
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
309
+ animation: slideUp 0.3s ease;
310
+ }
311
+ @media (max-width: 480px), (max-height: 480px) {
312
+ .sso-frame {
313
+ width: 100%;
314
+ height: 100%;
315
+ border-radius: 0;
316
+ }
317
+ }
318
+ .sso-close-btn {
319
+ position: absolute;
320
+ top: 12px;
321
+ right: 12px;
322
+ width: 32px;
323
+ height: 32px;
324
+ background: rgba(0, 0, 0, 0.4);
325
+ color: white;
326
+ border: none;
327
+ border-radius: 50%;
328
+ font-size: 24px;
329
+ line-height: 1;
330
+ cursor: pointer;
331
+ display: flex;
332
+ align-items: center;
333
+ justify-content: center;
334
+ z-index: 1000000;
335
+ transition: background 0.2s;
336
+ }
337
+ .sso-close-btn:hover {
338
+ background: rgba(0, 0, 0, 0.8);
339
+ }
340
+ .sso-overlay.sso-closing {
341
+ animation: fadeOut 0.2s ease forwards;
342
+ }
343
+ .sso-overlay.sso-closing .sso-frame {
344
+ animation: slideDown 0.2s ease forwards;
345
+ }
346
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
347
+ @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
348
+ @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
349
+ @keyframes slideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(20px); opacity: 0; } }
350
+ `;
351
+ }
352
+ // ─── Helpers ──────────────────────────────────────────────────────
353
+ buildFallbackUrl() {
354
+ const url = new URL(`${this.options.ssoOrigin}/authorize`);
355
+ url.searchParams.set("client_id", this.options.clientId);
356
+ url.searchParams.set("response_type", "code");
357
+ url.searchParams.set("redirect_uri", this.options.redirectUri || window.location.origin);
358
+ url.searchParams.set("state", generateRandomId());
359
+ url.searchParams.set("code_challenge_method", "S256");
360
+ return url.toString();
361
+ }
362
+ debug(...args) {
363
+ if (this.options.debug) {
364
+ console.log("[BigsoAuth]", ...args);
365
+ }
366
+ }
367
+ };
368
+ // Annotate the CommonJS export names for ESM import in node:
369
+ 0 && (module.exports = {
370
+ BigsoAuth
371
+ });
@@ -0,0 +1,66 @@
1
+ declare class EventEmitter {
2
+ private events;
3
+ on(event: string, handler: Function): void;
4
+ off(event: string, handler: Function): void;
5
+ emit(event: string, data?: any): void;
6
+ }
7
+
8
+ interface BigsoAuthOptions {
9
+ /** Client ID registrado en el SSO */
10
+ clientId: string;
11
+ /** Origen del SSO (ej: https://sso.bigso.co) */
12
+ ssoOrigin: string;
13
+ /** URL del JWKS para verificar firmas (ej: https://sso.bigso.co/.well-known/jwks.json) */
14
+ jwksUrl: string;
15
+ /** Timeout en milisegundos (por defecto 5000) */
16
+ timeout?: number;
17
+ /** Activar logs de depuración */
18
+ debug?: boolean;
19
+ /** URI de redirección registrada (opcional, si se requiere validación exacta) */
20
+ redirectUri?: string;
21
+ /** Sugerencia de tenant (opcional) */
22
+ tenantHint?: string;
23
+ /** Tema visual del iframe ('light' | 'dark', por defecto 'light') */
24
+ theme?: 'light' | 'dark';
25
+ }
26
+
27
+ declare class BigsoAuth extends EventEmitter {
28
+ private options;
29
+ private iframe?;
30
+ private authCompleted;
31
+ private requestId;
32
+ private timeoutId?;
33
+ private messageListener?;
34
+ private abortController?;
35
+ private hostEl?;
36
+ private shadowRoot?;
37
+ private overlayEl?;
38
+ private loginInProgress;
39
+ constructor(options: BigsoAuthOptions);
40
+ /**
41
+ * Inicia el flujo de autenticación.
42
+ * @returns Promise que resuelve con el payload decodificado del JWS (solo para información; el backend debe validar)
43
+ */
44
+ login(): Promise<any>;
45
+ /** Cancela el flujo de autenticación en curso */
46
+ abort(): void;
47
+ /**
48
+ * Crea (o reutiliza) el overlay con Shadow DOM y el iframe visible.
49
+ * Patrón tomado del CDN widget v1: Shadow DOM para aislar estilos.
50
+ */
51
+ private createUI;
52
+ /**
53
+ * Cierra el overlay con animación suave (fadeOut + slideDown).
54
+ * El overlay persiste en el DOM (solo se oculta).
55
+ */
56
+ private closeUI;
57
+ /**
58
+ * Estilos CSS encapsulados dentro del Shadow DOM.
59
+ * Migrados del widget CDN v1 con las mismas animaciones y responsive.
60
+ */
61
+ private getOverlayStyles;
62
+ private buildFallbackUrl;
63
+ private debug;
64
+ }
65
+
66
+ export { BigsoAuth, type BigsoAuthOptions };
@@ -0,0 +1,66 @@
1
+ declare class EventEmitter {
2
+ private events;
3
+ on(event: string, handler: Function): void;
4
+ off(event: string, handler: Function): void;
5
+ emit(event: string, data?: any): void;
6
+ }
7
+
8
+ interface BigsoAuthOptions {
9
+ /** Client ID registrado en el SSO */
10
+ clientId: string;
11
+ /** Origen del SSO (ej: https://sso.bigso.co) */
12
+ ssoOrigin: string;
13
+ /** URL del JWKS para verificar firmas (ej: https://sso.bigso.co/.well-known/jwks.json) */
14
+ jwksUrl: string;
15
+ /** Timeout en milisegundos (por defecto 5000) */
16
+ timeout?: number;
17
+ /** Activar logs de depuración */
18
+ debug?: boolean;
19
+ /** URI de redirección registrada (opcional, si se requiere validación exacta) */
20
+ redirectUri?: string;
21
+ /** Sugerencia de tenant (opcional) */
22
+ tenantHint?: string;
23
+ /** Tema visual del iframe ('light' | 'dark', por defecto 'light') */
24
+ theme?: 'light' | 'dark';
25
+ }
26
+
27
+ declare class BigsoAuth extends EventEmitter {
28
+ private options;
29
+ private iframe?;
30
+ private authCompleted;
31
+ private requestId;
32
+ private timeoutId?;
33
+ private messageListener?;
34
+ private abortController?;
35
+ private hostEl?;
36
+ private shadowRoot?;
37
+ private overlayEl?;
38
+ private loginInProgress;
39
+ constructor(options: BigsoAuthOptions);
40
+ /**
41
+ * Inicia el flujo de autenticación.
42
+ * @returns Promise que resuelve con el payload decodificado del JWS (solo para información; el backend debe validar)
43
+ */
44
+ login(): Promise<any>;
45
+ /** Cancela el flujo de autenticación en curso */
46
+ abort(): void;
47
+ /**
48
+ * Crea (o reutiliza) el overlay con Shadow DOM y el iframe visible.
49
+ * Patrón tomado del CDN widget v1: Shadow DOM para aislar estilos.
50
+ */
51
+ private createUI;
52
+ /**
53
+ * Cierra el overlay con animación suave (fadeOut + slideDown).
54
+ * El overlay persiste en el DOM (solo se oculta).
55
+ */
56
+ private closeUI;
57
+ /**
58
+ * Estilos CSS encapsulados dentro del Shadow DOM.
59
+ * Migrados del widget CDN v1 con las mismas animaciones y responsive.
60
+ */
61
+ private getOverlayStyles;
62
+ private buildFallbackUrl;
63
+ private debug;
64
+ }
65
+
66
+ export { BigsoAuth, type BigsoAuthOptions };
package/dist/index.js ADDED
@@ -0,0 +1,344 @@
1
+ // src/utils/crypto.ts
2
+ async function sha256Base64Url(input) {
3
+ const encoder = new TextEncoder();
4
+ const data = encoder.encode(input);
5
+ const digest = await crypto.subtle.digest("SHA-256", data);
6
+ return base64Url(new Uint8Array(digest));
7
+ }
8
+ function generateVerifier(length = 32) {
9
+ const array = new Uint8Array(length);
10
+ crypto.getRandomValues(array);
11
+ return base64Url(array);
12
+ }
13
+ function base64Url(bytes) {
14
+ return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
15
+ }
16
+ function generateRandomId() {
17
+ return crypto.randomUUID();
18
+ }
19
+
20
+ // src/utils/events.ts
21
+ var EventEmitter = class {
22
+ constructor() {
23
+ this.events = {};
24
+ }
25
+ on(event, handler) {
26
+ if (!this.events[event]) this.events[event] = [];
27
+ this.events[event].push(handler);
28
+ }
29
+ off(event, handler) {
30
+ if (!this.events[event]) return;
31
+ this.events[event] = this.events[event].filter((h) => h !== handler);
32
+ }
33
+ emit(event, data) {
34
+ this.events[event]?.forEach((fn) => fn(data));
35
+ }
36
+ };
37
+
38
+ // src/utils/jws.ts
39
+ import { jwtVerify, createRemoteJWKSet } from "jose";
40
+ async function verifySignedPayload(token, jwksUrl, expectedAudience) {
41
+ const JWKS = createRemoteJWKSet(new URL(jwksUrl));
42
+ const { payload } = await jwtVerify(token, JWKS, {
43
+ audience: expectedAudience
44
+ });
45
+ return payload;
46
+ }
47
+
48
+ // src/core/auth.ts
49
+ var BigsoAuth = class extends EventEmitter {
50
+ constructor(options) {
51
+ super();
52
+ this.authCompleted = false;
53
+ this.requestId = generateRandomId();
54
+ this.loginInProgress = false;
55
+ this.options = {
56
+ timeout: 5e3,
57
+ // por defecto 5s (estándar v2.3)
58
+ debug: false,
59
+ redirectUri: "",
60
+ tenantHint: "",
61
+ theme: "light",
62
+ ...options
63
+ };
64
+ }
65
+ /**
66
+ * Inicia el flujo de autenticación.
67
+ * @returns Promise que resuelve con el payload decodificado del JWS (solo para información; el backend debe validar)
68
+ */
69
+ async login() {
70
+ if (this.loginInProgress) {
71
+ this.debug("login() ya en curso, ignorando llamada duplicada");
72
+ return Promise.reject(new Error("Login already in progress"));
73
+ }
74
+ this.loginInProgress = true;
75
+ this.authCompleted = false;
76
+ const state = generateRandomId();
77
+ const nonce = generateRandomId();
78
+ const verifier = generateVerifier();
79
+ const requestId = this.requestId;
80
+ sessionStorage.setItem("sso_ctx", JSON.stringify({ state, nonce, verifier, requestId }));
81
+ this.createUI();
82
+ return new Promise((resolve, reject) => {
83
+ this.abortController = new AbortController();
84
+ const { signal } = this.abortController;
85
+ const cleanup = () => {
86
+ if (this.timeoutId) clearTimeout(this.timeoutId);
87
+ if (this.messageListener) window.removeEventListener("message", this.messageListener);
88
+ this.iframe?.remove();
89
+ this.iframe = void 0;
90
+ this.authCompleted = true;
91
+ this.loginInProgress = false;
92
+ };
93
+ this.messageListener = async (event) => {
94
+ if (event.origin !== this.options.ssoOrigin) {
95
+ this.debug("Ignorado mensaje de origen no autorizado:", event.origin);
96
+ return;
97
+ }
98
+ const msg = event.data;
99
+ this.debug("Mensaje recibido:", msg);
100
+ if (msg.requestId && msg.requestId !== requestId) {
101
+ this.debug("requestId no coincide, ignorado");
102
+ return;
103
+ }
104
+ if (msg.type === "sso-ready") {
105
+ this.debug("sso-ready recibido, iniciando timeout y enviando sso-init");
106
+ this.timeoutId = window.setTimeout(() => {
107
+ if (!this.authCompleted) {
108
+ this.debug("Timeout alcanzado, activando fallback");
109
+ this.closeUI();
110
+ cleanup();
111
+ this.emit("fallback");
112
+ window.location.href = this.buildFallbackUrl();
113
+ reject(new Error("Timeout"));
114
+ }
115
+ }, this.options.timeout);
116
+ const codeChallenge = await sha256Base64Url(verifier);
117
+ const initPayload = {
118
+ state,
119
+ nonce,
120
+ code_challenge: codeChallenge,
121
+ code_challenge_method: "S256",
122
+ origin: window.location.origin,
123
+ ...this.options.redirectUri && { redirect_uri: this.options.redirectUri },
124
+ ...this.options.tenantHint && { tenant_hint: this.options.tenantHint },
125
+ timeout_ms: this.options.timeout
126
+ // pasar el timeout configurado (opcional)
127
+ };
128
+ this.iframe?.contentWindow?.postMessage({
129
+ v: "2.3",
130
+ // versión del protocolo (estándar v2.3)
131
+ source: "@app/widget",
132
+ type: "sso-init",
133
+ requestId: this.requestId,
134
+ payload: initPayload
135
+ }, this.options.ssoOrigin);
136
+ this.emit("ready");
137
+ return;
138
+ }
139
+ if (msg.type === "sso-success") {
140
+ this.debug("sso-success recibido");
141
+ clearTimeout(this.timeoutId);
142
+ try {
143
+ const payload = msg.payload;
144
+ const ctx = JSON.parse(sessionStorage.getItem("sso_ctx") || "{}");
145
+ if (payload.state !== ctx.state) {
146
+ throw new Error("Invalid state");
147
+ }
148
+ const decoded = await verifySignedPayload(
149
+ payload.signed_payload,
150
+ this.options.jwksUrl,
151
+ window.location.origin
152
+ // aud esperado
153
+ );
154
+ if (decoded.nonce !== ctx.nonce) {
155
+ throw new Error("Invalid nonce");
156
+ }
157
+ this.debug("JWS v\xE1lido, payload:", decoded);
158
+ this.closeUI();
159
+ cleanup();
160
+ this.emit("success", decoded);
161
+ resolve(decoded);
162
+ } catch (err) {
163
+ this.debug("Error en sso-success:", err);
164
+ this.closeUI();
165
+ cleanup();
166
+ this.emit("error", err);
167
+ reject(err);
168
+ }
169
+ return;
170
+ }
171
+ if (msg.type === "sso-error") {
172
+ const errorPayload = msg.payload;
173
+ this.debug("sso-error recibido:", errorPayload);
174
+ clearTimeout(this.timeoutId);
175
+ this.closeUI();
176
+ cleanup();
177
+ if (errorPayload.code === "version_mismatch") {
178
+ this.emit("error", errorPayload);
179
+ window.location.href = this.buildFallbackUrl();
180
+ reject(new Error(`Version mismatch: expected ${errorPayload.expected_version}`));
181
+ } else {
182
+ this.emit("error", errorPayload);
183
+ reject(errorPayload);
184
+ }
185
+ }
186
+ if (msg.type === "sso-close") {
187
+ this.debug("sso-close recibido");
188
+ this.closeUI();
189
+ cleanup();
190
+ reject(new Error("Login cancelled by user"));
191
+ }
192
+ };
193
+ window.addEventListener("message", this.messageListener);
194
+ signal.addEventListener("abort", () => {
195
+ this.debug("Operaci\xF3n abortada");
196
+ this.closeUI();
197
+ cleanup();
198
+ reject(new Error("Login aborted"));
199
+ });
200
+ });
201
+ }
202
+ /** Cancela el flujo de autenticación en curso */
203
+ abort() {
204
+ this.abortController?.abort();
205
+ }
206
+ // ─── UI Management ───────────────────────────────────────────────
207
+ /**
208
+ * Crea (o reutiliza) el overlay con Shadow DOM y el iframe visible.
209
+ * Patrón tomado del CDN widget v1: Shadow DOM para aislar estilos.
210
+ */
211
+ createUI() {
212
+ if (!this.hostEl) {
213
+ this.hostEl = document.createElement("div");
214
+ this.hostEl.id = "bigso-auth-host";
215
+ this.shadowRoot = this.hostEl.attachShadow({ mode: "open" });
216
+ const style = document.createElement("style");
217
+ style.textContent = this.getOverlayStyles();
218
+ this.shadowRoot.appendChild(style);
219
+ this.overlayEl = document.createElement("div");
220
+ this.overlayEl.className = "sso-overlay";
221
+ const closeBtn = document.createElement("button");
222
+ closeBtn.className = "sso-close-btn";
223
+ closeBtn.innerHTML = "&times;";
224
+ closeBtn.setAttribute("aria-label", "Cerrar modal");
225
+ closeBtn.addEventListener("click", () => this.abort());
226
+ this.overlayEl.appendChild(closeBtn);
227
+ this.overlayEl.addEventListener("click", (event) => {
228
+ if (event.target === this.overlayEl) {
229
+ this.abort();
230
+ }
231
+ });
232
+ this.shadowRoot.appendChild(this.overlayEl);
233
+ document.body.appendChild(this.hostEl);
234
+ }
235
+ this.iframe = document.createElement("iframe");
236
+ this.iframe.className = "sso-frame";
237
+ this.iframe.src = `${this.options.ssoOrigin}/auth/sign-in?v=2.3&client_id=${this.options.clientId}&embedded=true&app_id=${this.options.clientId}&theme=${this.options.theme}`;
238
+ this.iframe.setAttribute("title", "SSO Login");
239
+ this.overlayEl.appendChild(this.iframe);
240
+ this.debug("Iframe creado", this.iframe.src);
241
+ this.overlayEl.classList.remove("sso-closing");
242
+ this.overlayEl.style.display = "flex";
243
+ }
244
+ /**
245
+ * Cierra el overlay con animación suave (fadeOut + slideDown).
246
+ * El overlay persiste en el DOM (solo se oculta).
247
+ */
248
+ closeUI() {
249
+ if (!this.overlayEl || this.overlayEl.style.display === "none") return;
250
+ this.overlayEl.classList.add("sso-closing");
251
+ setTimeout(() => {
252
+ if (this.overlayEl) {
253
+ this.overlayEl.style.display = "none";
254
+ this.overlayEl.classList.remove("sso-closing");
255
+ }
256
+ }, 200);
257
+ }
258
+ /**
259
+ * Estilos CSS encapsulados dentro del Shadow DOM.
260
+ * Migrados del widget CDN v1 con las mismas animaciones y responsive.
261
+ */
262
+ getOverlayStyles() {
263
+ return `
264
+ .sso-overlay {
265
+ position: fixed;
266
+ inset: 0;
267
+ display: none;
268
+ justify-content: center;
269
+ align-items: center;
270
+ background: rgba(0, 0, 0, 0.6);
271
+ z-index: 999999;
272
+ backdrop-filter: blur(4px);
273
+ -webkit-backdrop-filter: blur(4px);
274
+ animation: fadeIn 0.2s ease;
275
+ }
276
+ .sso-frame {
277
+ width: 370px;
278
+ height: 350px;
279
+ border: none;
280
+ border-radius: 16px;
281
+ background: var(--card-bg, #fff);
282
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
283
+ animation: slideUp 0.3s ease;
284
+ }
285
+ @media (max-width: 480px), (max-height: 480px) {
286
+ .sso-frame {
287
+ width: 100%;
288
+ height: 100%;
289
+ border-radius: 0;
290
+ }
291
+ }
292
+ .sso-close-btn {
293
+ position: absolute;
294
+ top: 12px;
295
+ right: 12px;
296
+ width: 32px;
297
+ height: 32px;
298
+ background: rgba(0, 0, 0, 0.4);
299
+ color: white;
300
+ border: none;
301
+ border-radius: 50%;
302
+ font-size: 24px;
303
+ line-height: 1;
304
+ cursor: pointer;
305
+ display: flex;
306
+ align-items: center;
307
+ justify-content: center;
308
+ z-index: 1000000;
309
+ transition: background 0.2s;
310
+ }
311
+ .sso-close-btn:hover {
312
+ background: rgba(0, 0, 0, 0.8);
313
+ }
314
+ .sso-overlay.sso-closing {
315
+ animation: fadeOut 0.2s ease forwards;
316
+ }
317
+ .sso-overlay.sso-closing .sso-frame {
318
+ animation: slideDown 0.2s ease forwards;
319
+ }
320
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
321
+ @keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
322
+ @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
323
+ @keyframes slideDown { from { transform: translateY(0); opacity: 1; } to { transform: translateY(20px); opacity: 0; } }
324
+ `;
325
+ }
326
+ // ─── Helpers ──────────────────────────────────────────────────────
327
+ buildFallbackUrl() {
328
+ const url = new URL(`${this.options.ssoOrigin}/authorize`);
329
+ url.searchParams.set("client_id", this.options.clientId);
330
+ url.searchParams.set("response_type", "code");
331
+ url.searchParams.set("redirect_uri", this.options.redirectUri || window.location.origin);
332
+ url.searchParams.set("state", generateRandomId());
333
+ url.searchParams.set("code_challenge_method", "S256");
334
+ return url.toString();
335
+ }
336
+ debug(...args) {
337
+ if (this.options.debug) {
338
+ console.log("[BigsoAuth]", ...args);
339
+ }
340
+ }
341
+ };
342
+ export {
343
+ BigsoAuth
344
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigso/auth-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.2",
4
4
  "description": "SDK de autenticación para SSO v2.3 con iframe seguro",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org/",
@@ -16,7 +16,8 @@
16
16
  "build": "tsup src/index.ts --format esm,cjs --dts",
17
17
  "dev": "tsup src/index.ts --watch",
18
18
  "lint": "eslint .",
19
- "test": "vitest"
19
+ "test": "vitest",
20
+ "release": "git tag v$npm_package_version && git push origin v$npm_package_version"
20
21
  },
21
22
  "dependencies": {
22
23
  "jose": "^5.0.0"