@robosophy/auth 0.0.0-stage → 1.0.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 +141 -3
- package/dist/index.d.ts +32 -0
- package/dist/index.js +182 -0
- package/package.json +35 -6
package/README.md
CHANGED
|
@@ -1,3 +1,141 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# @robosophy/auth
|
|
2
|
+
|
|
3
|
+
Sesión de los fronts de Mi Cuenta contra el Landing: access token RS256 de 15 minutos en `localStorage["token"]`, refresh opaco en cookie HttpOnly y datos del usuario desde `GET /api/usuario/self`.
|
|
4
|
+
|
|
5
|
+
No depende de ningún framework. Necesita `axios` (peer dependency) y `fetch`.
|
|
6
|
+
|
|
7
|
+
## Instalación
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @robosophy/auth
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuración
|
|
14
|
+
|
|
15
|
+
Se llama una sola vez, al arrancar la app:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { configureAuth } from "@robosophy/auth";
|
|
19
|
+
|
|
20
|
+
configureAuth({
|
|
21
|
+
landingUrl: import.meta.env.VITE_LANDING_URL, // origin del Landing (front y back comparten origin)
|
|
22
|
+
devEmail: import.meta.env.VITE_DEV_EMAIL, // solo DEV
|
|
23
|
+
isDev: import.meta.env.DEV,
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## API
|
|
28
|
+
|
|
29
|
+
| Función | Qué hace |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `configureAuth(opts)` | Guarda `landingUrl`, `devEmail` e `isDev`. |
|
|
32
|
+
| `getToken()` | Devuelve `localStorage["token"]` o `null`. |
|
|
33
|
+
| `attachAuthRefresh(api)` | Agrega el Bearer a cada request y ante un 401 renueva el token y reintenta una vez. |
|
|
34
|
+
| `getCurrentUser()` | `GET {landingUrl}/api/usuario/self`, cacheado en memoria. Si falla, el cache se descarta. |
|
|
35
|
+
| `setSession(token)` | Guarda el token nuevo e invalida el cache del usuario. |
|
|
36
|
+
| `logout()` | `POST {landingUrl}/api/auth/logout`, limpia la sesión y va a `{landingUrl}/login`. |
|
|
37
|
+
| `clearSession()` | Borra `token`, `user-id`, `originalToken` y `token-landing` de `localStorage`. |
|
|
38
|
+
|
|
39
|
+
## Cómo se renueva el token
|
|
40
|
+
|
|
41
|
+
Ante un 401 de un request que todavía no se reintentó:
|
|
42
|
+
|
|
43
|
+
1. Se toma el lock `basf-auth-refresh` con `navigator.locks`, compartido entre pestañas. Si el navegador no lo soporta, se deduplica solo dentro de la pestaña.
|
|
44
|
+
2. Si `localStorage["token"]` ya cambió, otra pestaña renovó primero y se usa ese token.
|
|
45
|
+
3. Si no, se pide uno nuevo:
|
|
46
|
+
- En DEV con `devEmail`: `POST {landingUrl}/dev/token` con `{ email }`. El Landing back local lo expone solo en el perfil `local`.
|
|
47
|
+
- Si no: `POST {landingUrl}/api/auth/refresh` con `credentials: "include"`, que rota la cookie de refresh.
|
|
48
|
+
4. Con token nuevo se reintenta el request original. Si no hay token:
|
|
49
|
+
- En DEV: `console.warn("[auth] sin sesión: definí VITE_DEV_EMAIL en .env.local")` y el error sigue. No redirige.
|
|
50
|
+
- En prod: `clearSession()` y redirect a `{landingUrl}/login`, salvo que ya esté en esa página.
|
|
51
|
+
|
|
52
|
+
Los 401 de `/api/auth/login`, `/api/auth/verify-2fa`, `/api/auth/resend-2fa`, `/api/auth/refresh`, `/api/auth/logout` y `/dev/token` no disparan el refresh: ahí el 401 es la respuesta (credenciales, código 2FA, refresh vencido). El resto de `/api/auth/*`, como `request-2fa-challenge`, sí renueva y reintenta.
|
|
53
|
+
|
|
54
|
+
## Uso en Vue
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
// main.js
|
|
58
|
+
import { configureAuth } from "@robosophy/auth";
|
|
59
|
+
|
|
60
|
+
configureAuth({
|
|
61
|
+
landingUrl: import.meta.env.VITE_LANDING_URL,
|
|
62
|
+
devEmail: import.meta.env.VITE_DEV_EMAIL,
|
|
63
|
+
isDev: import.meta.env.DEV,
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
// axiosClient.js
|
|
69
|
+
import axios from "axios";
|
|
70
|
+
import { attachAuthRefresh } from "@robosophy/auth";
|
|
71
|
+
|
|
72
|
+
const api = axios.create({ baseURL: import.meta.env.VITE_URL });
|
|
73
|
+
attachAuthRefresh(api);
|
|
74
|
+
export default api;
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
// router/index.js
|
|
79
|
+
import { getCurrentUser } from "@robosophy/auth";
|
|
80
|
+
|
|
81
|
+
router.beforeEach(async (to) => {
|
|
82
|
+
if (!to.meta.requiresAuth) return true;
|
|
83
|
+
try {
|
|
84
|
+
const user = await getCurrentUser();
|
|
85
|
+
return Boolean(user?.rolCYO?.rol);
|
|
86
|
+
} catch {
|
|
87
|
+
return false; // attachAuthRefresh ya redirigió al login (prod) o avisó por consola (DEV)
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```vue
|
|
93
|
+
<!-- Botón de salir -->
|
|
94
|
+
<script setup>
|
|
95
|
+
import { logout } from "@robosophy/auth";
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<button @click="logout">Salir</button>
|
|
100
|
+
</template>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Uso en React
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
// main.tsx
|
|
107
|
+
import { configureAuth } from "@robosophy/auth";
|
|
108
|
+
|
|
109
|
+
configureAuth({
|
|
110
|
+
landingUrl: import.meta.env.VITE_LANDING_URL,
|
|
111
|
+
devEmail: import.meta.env.VITE_DEV_EMAIL,
|
|
112
|
+
isDev: import.meta.env.DEV,
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
// useCurrentUser.ts
|
|
118
|
+
import { useEffect, useState } from "react";
|
|
119
|
+
import { getCurrentUser } from "@robosophy/auth";
|
|
120
|
+
|
|
121
|
+
export function useCurrentUser<T>() {
|
|
122
|
+
const [user, setUser] = useState<T | null>(null);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
getCurrentUser<T>().then(setUser).catch(() => setUser(null));
|
|
125
|
+
}, []);
|
|
126
|
+
return user;
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
import { logout } from "@robosophy/auth";
|
|
132
|
+
|
|
133
|
+
<button onClick={() => logout()}>Salir</button>;
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Desarrollo local
|
|
137
|
+
|
|
138
|
+
- La variable con la URL del Landing (`landingUrl`) apunta al **Landing back** en `http://localhost:8184`, no al Vite del front de Landing: ahí están `/api/usuario/self` y `/dev/token`.
|
|
139
|
+
- `VITE_DEV_EMAIL=usuario@basf.com` va en `.env.local`, que no se commitea.
|
|
140
|
+
- Con el Landing back en perfil `local`, el front pide el token solo en el primer 401: no hace falta loguearse ni copiar tokens.
|
|
141
|
+
- Para entrar con otro usuario se cambia el email y se recarga. `configureAuth` detecta el cambio (lo guarda en `localStorage["auth-dev-email"]`) y borra la sesión del usuario anterior.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
interface AuthOptions {
|
|
2
|
+
/** Origin del Landing (front y back comparten origin): ej. import.meta.env.VITE_LANDING_URL */
|
|
3
|
+
landingUrl: string;
|
|
4
|
+
/** Solo DEV: email para pedir el token a POST /dev/token del Landing back local. */
|
|
5
|
+
devEmail?: string;
|
|
6
|
+
/** import.meta.env.DEV */
|
|
7
|
+
isDev?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Lo único que se usa de la instancia. Estructural para no atar los tipos a la versión de axios
|
|
11
|
+
* del paquete: cada front pasa la instancia de su propio axios.
|
|
12
|
+
*/
|
|
13
|
+
interface AxiosLike {
|
|
14
|
+
interceptors: {
|
|
15
|
+
request: {
|
|
16
|
+
use(onFulfilled: (config: any) => any): unknown;
|
|
17
|
+
};
|
|
18
|
+
response: {
|
|
19
|
+
use(onFulfilled: undefined, onRejected: (error: any) => any): unknown;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
request(config: any): Promise<any>;
|
|
23
|
+
}
|
|
24
|
+
declare function configureAuth(opts: AuthOptions): void;
|
|
25
|
+
declare function getToken(): string | null;
|
|
26
|
+
declare function setSession(token: string): void;
|
|
27
|
+
declare function clearSession(): void;
|
|
28
|
+
declare function attachAuthRefresh(api: AxiosLike): void;
|
|
29
|
+
declare function getCurrentUser<T = any>(): Promise<T>;
|
|
30
|
+
declare function logout(): Promise<void>;
|
|
31
|
+
|
|
32
|
+
export { type AuthOptions, type AxiosLike, attachAuthRefresh, clearSession, configureAuth, getCurrentUser, getToken, logout, setSession };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
var TOKEN_KEY = "token";
|
|
4
|
+
var SESSION_KEYS = [TOKEN_KEY, "user-id", "originalToken", "token-landing"];
|
|
5
|
+
var LOCK_NAME = "basf-auth-refresh";
|
|
6
|
+
var REFRESH_PATH = "/api/auth/refresh";
|
|
7
|
+
var DEV_TOKEN_PATH = "/dev/token";
|
|
8
|
+
var LOGOUT_PATH = "/api/auth/logout";
|
|
9
|
+
var SELF_PATH = "/api/usuario/self";
|
|
10
|
+
var DEV_EMAIL_KEY = "auth-dev-email";
|
|
11
|
+
var NO_RETRY_PATHS = [
|
|
12
|
+
"/api/auth/login",
|
|
13
|
+
"/api/auth/verify-2fa",
|
|
14
|
+
"/api/auth/resend-2fa",
|
|
15
|
+
REFRESH_PATH,
|
|
16
|
+
LOGOUT_PATH,
|
|
17
|
+
DEV_TOKEN_PATH
|
|
18
|
+
];
|
|
19
|
+
var options = null;
|
|
20
|
+
var landingApi = null;
|
|
21
|
+
var currentUser = null;
|
|
22
|
+
var inFlightRefresh = null;
|
|
23
|
+
function configureAuth(opts) {
|
|
24
|
+
options = {
|
|
25
|
+
landingUrl: opts.landingUrl.replace(/\/+$/, ""),
|
|
26
|
+
devEmail: opts.devEmail ?? "",
|
|
27
|
+
isDev: opts.isDev ?? false
|
|
28
|
+
};
|
|
29
|
+
landingApi = null;
|
|
30
|
+
currentUser = null;
|
|
31
|
+
if (options.isDev && options.devEmail !== "" && localStorage.getItem(DEV_EMAIL_KEY) !== options.devEmail) {
|
|
32
|
+
clearSession();
|
|
33
|
+
localStorage.setItem(DEV_EMAIL_KEY, options.devEmail);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function getToken() {
|
|
37
|
+
return localStorage.getItem(TOKEN_KEY);
|
|
38
|
+
}
|
|
39
|
+
function setSession(token) {
|
|
40
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
41
|
+
currentUser = null;
|
|
42
|
+
}
|
|
43
|
+
function clearSession() {
|
|
44
|
+
SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
|
|
45
|
+
currentUser = null;
|
|
46
|
+
}
|
|
47
|
+
function attachAuthRefresh(api) {
|
|
48
|
+
api.interceptors.request.use((config) => {
|
|
49
|
+
const token = getToken();
|
|
50
|
+
if (token) {
|
|
51
|
+
setBearer(config, token);
|
|
52
|
+
}
|
|
53
|
+
return config;
|
|
54
|
+
});
|
|
55
|
+
api.interceptors.response.use(void 0, async (error) => {
|
|
56
|
+
const config = error?.config;
|
|
57
|
+
if (error?.response?.status !== 401 || !config || config._retried || isAuthEndpoint(config)) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
config._retried = true;
|
|
61
|
+
const token = await renewToken(bearerOf(config));
|
|
62
|
+
if (!token) {
|
|
63
|
+
onSessionLost();
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
setBearer(config, token);
|
|
67
|
+
return api.request(config);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function getCurrentUser() {
|
|
71
|
+
if (!currentUser) {
|
|
72
|
+
const request = getLandingApi().get(SELF_PATH).then((response) => response.data);
|
|
73
|
+
currentUser = request;
|
|
74
|
+
request.catch(() => {
|
|
75
|
+
if (currentUser === request) {
|
|
76
|
+
currentUser = null;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return currentUser;
|
|
81
|
+
}
|
|
82
|
+
async function logout() {
|
|
83
|
+
const { landingUrl } = requireOptions();
|
|
84
|
+
const token = getToken();
|
|
85
|
+
try {
|
|
86
|
+
await fetch(landingUrl + LOGOUT_PATH, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
credentials: "include",
|
|
89
|
+
headers: token ? { Authorization: `Bearer ${token}` } : void 0
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
}
|
|
93
|
+
clearSession();
|
|
94
|
+
window.location.href = `${landingUrl}/login`;
|
|
95
|
+
}
|
|
96
|
+
function requireOptions() {
|
|
97
|
+
if (!options) {
|
|
98
|
+
throw new Error("[auth] falta llamar a configureAuth() en main");
|
|
99
|
+
}
|
|
100
|
+
return options;
|
|
101
|
+
}
|
|
102
|
+
function getLandingApi() {
|
|
103
|
+
if (!landingApi) {
|
|
104
|
+
landingApi = axios.create({ baseURL: requireOptions().landingUrl });
|
|
105
|
+
attachAuthRefresh(landingApi);
|
|
106
|
+
}
|
|
107
|
+
return landingApi;
|
|
108
|
+
}
|
|
109
|
+
function isAuthEndpoint(config) {
|
|
110
|
+
const url = config.url ?? "";
|
|
111
|
+
return NO_RETRY_PATHS.some((path) => url.includes(path));
|
|
112
|
+
}
|
|
113
|
+
function setBearer(config, token) {
|
|
114
|
+
config.headers = config.headers ?? {};
|
|
115
|
+
config.headers["Authorization"] = `Bearer ${token}`;
|
|
116
|
+
}
|
|
117
|
+
function bearerOf(config) {
|
|
118
|
+
const header = config.headers?.["Authorization"] ?? config.headers?.["authorization"];
|
|
119
|
+
return typeof header === "string" && header.startsWith("Bearer ") ? header.slice("Bearer ".length) : null;
|
|
120
|
+
}
|
|
121
|
+
function renewToken(stale) {
|
|
122
|
+
if (!inFlightRefresh) {
|
|
123
|
+
inFlightRefresh = withRefreshLock(async () => {
|
|
124
|
+
const current = getToken();
|
|
125
|
+
if (current && current !== stale) {
|
|
126
|
+
return current;
|
|
127
|
+
}
|
|
128
|
+
const token = await requestNewToken();
|
|
129
|
+
if (token) {
|
|
130
|
+
localStorage.setItem(TOKEN_KEY, token);
|
|
131
|
+
}
|
|
132
|
+
return token;
|
|
133
|
+
}).finally(() => {
|
|
134
|
+
inFlightRefresh = null;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return inFlightRefresh;
|
|
138
|
+
}
|
|
139
|
+
function withRefreshLock(task) {
|
|
140
|
+
const locks = typeof navigator !== "undefined" ? navigator.locks : void 0;
|
|
141
|
+
return locks ? locks.request(LOCK_NAME, task) : task();
|
|
142
|
+
}
|
|
143
|
+
async function requestNewToken() {
|
|
144
|
+
const { landingUrl, devEmail, isDev } = requireOptions();
|
|
145
|
+
const useDevToken = isDev && devEmail !== "";
|
|
146
|
+
try {
|
|
147
|
+
const response = await fetch(landingUrl + (useDevToken ? DEV_TOKEN_PATH : REFRESH_PATH), {
|
|
148
|
+
method: "POST",
|
|
149
|
+
credentials: "include",
|
|
150
|
+
headers: useDevToken ? { "Content-Type": "application/json" } : void 0,
|
|
151
|
+
body: useDevToken ? JSON.stringify({ email: devEmail }) : void 0
|
|
152
|
+
});
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const body = await response.json();
|
|
157
|
+
return typeof body?.value === "string" ? body.value : null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function onSessionLost() {
|
|
163
|
+
const { landingUrl, isDev } = requireOptions();
|
|
164
|
+
if (isDev) {
|
|
165
|
+
console.warn("[auth] sin sesi\xF3n: defin\xED VITE_DEV_EMAIL en .env.local");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
clearSession();
|
|
169
|
+
const loginUrl = `${landingUrl}/login`;
|
|
170
|
+
if (!window.location.href.startsWith(loginUrl)) {
|
|
171
|
+
window.location.href = loginUrl;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
attachAuthRefresh,
|
|
176
|
+
clearSession,
|
|
177
|
+
configureAuth,
|
|
178
|
+
getCurrentUser,
|
|
179
|
+
getToken,
|
|
180
|
+
logout,
|
|
181
|
+
setSession
|
|
182
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@robosophy/auth",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@robosophy/auth",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"package.json"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": false,
|
|
21
|
+
"scripts": {
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"build": "tsup src/index.ts --format esm --dts --clean --external axios",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"axios": ">=0.19.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"axios": "^1.7.0",
|
|
31
|
+
"tsup": "^8.5.0",
|
|
32
|
+
"typescript": "^5.9.3",
|
|
33
|
+
"vitest": "^4.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|