utn-mcp 0.8.0 → 0.8.1
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 +3 -3
- package/build/aula-virtual/client/link.js +3 -2
- package/build/aula-virtual/client/regionals.js +17 -8
- package/build/aula-virtual/client/sso-login.js +165 -14
- package/build/aula-virtual/login.js +2 -1
- package/build/oauth/admin.js +8 -3
- package/build/server.js +1 -1
- package/build/shared/store/sqlite-store.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Necesitás [Node.js](https://nodejs.org) 20 o superior. No hace falta clonar el
|
|
|
48
48
|
```bash
|
|
49
49
|
npx -y utn-mcp login
|
|
50
50
|
```
|
|
51
|
-
Te pide tu regional, tu usuario y tu contraseña: los mismos con los que entrás al aula virtual. Si no tenés usuario institucional (por ejemplo, en el curso de ingreso), usá el que te dieron para el aula. Se hace una sola vez: después el servidor renueva la sesión solo.
|
|
51
|
+
Te pide tu regional, tu usuario y tu contraseña: los mismos con los que entrás al aula virtual. Si no tenés usuario institucional (por ejemplo, en el curso de ingreso), usá el que te dieron para el aula. En Córdoba (FRC) los alumnos entran con el mail completo (`<legajo>@<especialidad>.frc.utn.edu.ar`), no con el legajo solo; con una cuenta `@frc.utn.edu.ar`, con lo que va antes de la `@`. En Haedo (FRH) y San Nicolás (FRSN) se entra con el mail completo de Office 365 (en FRH, del estilo `<inicial><apellido><3 dígitos del legajo>@alumnos.frh.utn.edu.ar`). Se hace una sola vez: después el servidor renueva la sesión solo.
|
|
52
52
|
|
|
53
53
|
### 2. Agregarlo a tu cliente
|
|
54
54
|
|
|
@@ -120,14 +120,14 @@ utn-mcp login
|
|
|
120
120
|
y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
|
|
121
121
|
|
|
122
122
|
### Regionales
|
|
123
|
-
La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. El login se adapta solo: si el aula entra por SSO (
|
|
123
|
+
La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Córdoba (FRC), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. También está el aula de la Tecnicatura en Programación a distancia del SIED (TUP). El login se adapta solo: si el aula entra por SSO, sea un Keycloak propio (FRBA) u Office 365 (Haedo, FRH, y San Nicolás, FRSN), usa el SSO; si no, el login directo de Moodle. Con Office 365 todavía no se pueden completar cuentas que piden segundo factor (MFA).
|
|
124
124
|
|
|
125
125
|
La lista puede traer regionales que nadie probó todavía: su aula tiene habilitado el servicio que usa UTN-MCP, así que casi seguro anda. En un servidor hosteado, la primera persona que entra con una de esas la deja verificada para el resto. Las que no tienen el servicio habilitado aparecen como "todavía sin soporte": se pueden elegir, pero el login avisa que por ahora no se puede usar y no manda la contraseña.
|
|
126
126
|
|
|
127
127
|
Si tu campus no está en la lista, abrí un [issue](https://github.com/nialvape/utn-mcp/issues) con la URL de su aula virtual para sumarlo. Mientras tanto, podés usarlo definiendo en un `.env` en la carpeta desde donde corrés `utn-mcp` (o en el entorno):
|
|
128
128
|
```bash
|
|
129
129
|
UTN_AULA_BASE_URL=https://tu-campus.utn.edu.ar
|
|
130
|
-
# UTN_SSO_HOST=auth.tu-campus.utn.edu.ar # solo si
|
|
130
|
+
# UTN_SSO_HOST=auth.tu-campus.utn.edu.ar # solo si entra por SSO: Keycloak, o login.microsoftonline.com si es Office 365
|
|
131
131
|
```
|
|
132
132
|
Aparece como una opción más al iniciar sesión.
|
|
133
133
|
|
|
@@ -20,14 +20,15 @@ export async function linkAulaAccount(store, regional, credentials) {
|
|
|
20
20
|
const own = await getOwnUser(fresh);
|
|
21
21
|
// La primera vez que alguien entra a una regional sin verificar se prueban también los cursos: si
|
|
22
22
|
// anda, queda verificada para todos los de este servidor.
|
|
23
|
+
let firstVerifier = false;
|
|
23
24
|
if (regional.support === "candidate") {
|
|
24
25
|
await getUserCourses(fresh);
|
|
25
|
-
await store.markRegionalVerified(regional.id);
|
|
26
|
+
firstVerifier = await store.markRegionalVerified(regional.id);
|
|
26
27
|
}
|
|
27
28
|
// El de Moodle, en minúscula, igual que en `users`: es con lo que se compara al renovar.
|
|
28
29
|
const username = own.username.toLowerCase();
|
|
29
30
|
const userId = await store.findOrCreateUser(regional.id, username, own.email ?? undefined);
|
|
30
31
|
const data = { username, token };
|
|
31
32
|
await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
|
|
32
|
-
return { userId, regional, username, fullname: own.fullname, email: own.email };
|
|
33
|
+
return { userId, regional, username, fullname: own.fullname, email: own.email, firstVerifier };
|
|
33
34
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Office 365: cada regional tiene su tenant, pero el login es siempre en este host. */
|
|
2
|
+
const MICROSOFT_SSO = { provider: "microsoft", host: "login.microsoftonline.com" };
|
|
1
3
|
/**
|
|
2
4
|
* El soporte de las que no son `verified` sale de `tool_mobile_get_public_config`, que Moodle contesta
|
|
3
5
|
* sin login (octubre de 2026). Falta Villa María (FRVM): no encontramos su aula.
|
|
@@ -9,7 +11,7 @@ const REGIONALS = [
|
|
|
9
11
|
id: "frba",
|
|
10
12
|
name: "Buenos Aires (FRBA)",
|
|
11
13
|
baseUrl: "https://aulasvirtuales.frba.utn.edu.ar",
|
|
12
|
-
|
|
14
|
+
sso: { provider: "keycloak", host: "auth.frba.utn.edu.ar" },
|
|
13
15
|
support: "verified",
|
|
14
16
|
},
|
|
15
17
|
{ id: "frch", name: "Chubut (FRCh)", baseUrl: "https://frch.cvg.utn.edu.ar", support: "candidate" },
|
|
@@ -18,11 +20,11 @@ const REGIONALS = [
|
|
|
18
20
|
{ id: "frc", name: "Córdoba (FRC)", baseUrl: "https://uv.frc.utn.edu.ar", support: "verified" },
|
|
19
21
|
{ id: "frd", name: "Delta (FRD)", baseUrl: "https://frd.cvg.utn.edu.ar", support: "candidate" },
|
|
20
22
|
{ id: "frgp", name: "General Pacheco (FRGP)", baseUrl: "https://frgp.cvg.utn.edu.ar", support: "candidate" },
|
|
21
|
-
|
|
22
|
-
{ id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", support: "unsupported" },
|
|
23
|
+
{ id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", sso: MICROSOFT_SSO, support: "candidate" },
|
|
23
24
|
{ id: "frlp", name: "La Plata (FRLP)", baseUrl: "https://frlp.cvg.utn.edu.ar", support: "candidate" },
|
|
24
25
|
{ id: "frlr", name: "La Rioja (FRLR)", baseUrl: "https://frlr.cvg.utn.edu.ar", support: "candidate" },
|
|
25
|
-
// Tiene también frmdp.cvg.utn.edu.ar, pero ese
|
|
26
|
+
// Tiene también frmdp.cvg.utn.edu.ar, pero ese entra con Office 365 y no tiene el mobile web service
|
|
27
|
+
// habilitado, así que no puede emitir token aunque se resuelva el login.
|
|
26
28
|
{ id: "frmdp", name: "Mar del Plata (FRMDP)", baseUrl: "https://campus.mdp.utn.edu.ar", support: "candidate" },
|
|
27
29
|
// Sin el servicio habilitado.
|
|
28
30
|
{ id: "frm", name: "Mendoza (FRM)", baseUrl: "https://campusvirtual.frm.utn.edu.ar", support: "unsupported" },
|
|
@@ -33,8 +35,7 @@ const REGIONALS = [
|
|
|
33
35
|
{ id: "frre", name: "Resistencia (FRRE)", baseUrl: "https://frre.cvg.utn.edu.ar", support: "verified" },
|
|
34
36
|
{ id: "frro", name: "Rosario (FRRo)", baseUrl: "https://frro.cvg.utn.edu.ar", support: "candidate" },
|
|
35
37
|
{ id: "frsfco", name: "San Francisco (FRSFco)", baseUrl: "https://frsfco.cvg.utn.edu.ar", support: "candidate" },
|
|
36
|
-
|
|
37
|
-
{ id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", support: "unsupported" },
|
|
38
|
+
{ id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", sso: MICROSOFT_SSO, support: "candidate" },
|
|
38
39
|
{ id: "frsr", name: "San Rafael (FRSR)", baseUrl: "https://campus.frsr.utn.edu.ar/moodle", support: "candidate" },
|
|
39
40
|
{ id: "frsc", name: "Santa Cruz (FRSC)", baseUrl: "https://frsc.cvg.utn.edu.ar", support: "candidate" },
|
|
40
41
|
{ id: "frsf", name: "Santa Fe (FRSF)", baseUrl: "https://campusvirtual.frsf.utn.edu.ar", support: "candidate" },
|
|
@@ -42,6 +43,8 @@ const REGIONALS = [
|
|
|
42
43
|
{ id: "frtdf", name: "Tierra del Fuego (FRTDF)", baseUrl: "https://cvg.frtdf.utn.edu.ar", support: "unsupported" },
|
|
43
44
|
{ id: "frtl", name: "Trenque Lauquen (FRTL)", baseUrl: "https://frtl.cvg.utn.edu.ar", support: "candidate" },
|
|
44
45
|
{ id: "frt", name: "Tucumán (FRT)", baseUrl: "https://frt.cvg.utn.edu.ar", support: "verified" },
|
|
46
|
+
// No es una regional sino el aula de la Tecnicatura en Programación a distancia del SIED.
|
|
47
|
+
{ id: "tup", name: "Tecnicatura en Programación (SIED)", baseUrl: "https://tup.sied.utn.edu.ar", support: "verified" },
|
|
45
48
|
// Sin el servicio habilitado.
|
|
46
49
|
{ id: "frvt", name: "Venado Tuerto (FRVT)", baseUrl: "https://frvt.cvg.utn.edu.ar", support: "unsupported" },
|
|
47
50
|
];
|
|
@@ -52,7 +55,7 @@ export class UnknownRegionalError extends Error {
|
|
|
52
55
|
constructor(regional) {
|
|
53
56
|
super(`"${regional}" no es una regional conocida. Las válidas son: ${availableRegionals().map((r) => r.id).join(", ")}. ` +
|
|
54
57
|
"Si tu campus no está en la lista, borrá UTN_REGIONAL y definí UTN_AULA_BASE_URL con la URL del aula " +
|
|
55
|
-
"(y UTN_SSO_HOST si
|
|
58
|
+
"(y UTN_SSO_HOST si entra por SSO: el host del Keycloak, o login.microsoftonline.com si es Office 365).");
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
/** Su aula no tiene el servicio habilitado o entra por un SSO que no sabemos usar: ni se intenta entrar. */
|
|
@@ -72,7 +75,13 @@ function customRegional() {
|
|
|
72
75
|
return undefined;
|
|
73
76
|
const baseUrl = raw.replace(/\/+$/, "");
|
|
74
77
|
const host = new URL(baseUrl).host;
|
|
75
|
-
|
|
78
|
+
const ssoHost = process.env.UTN_SSO_HOST?.trim();
|
|
79
|
+
const sso = !ssoHost
|
|
80
|
+
? undefined
|
|
81
|
+
: ssoHost === MICROSOFT_SSO.host
|
|
82
|
+
? MICROSOFT_SSO
|
|
83
|
+
: { provider: "keycloak", host: ssoHost };
|
|
84
|
+
return { id: host, name: host, baseUrl, sso, support: "verified" };
|
|
76
85
|
}
|
|
77
86
|
/**
|
|
78
87
|
* Las que se pueden elegir al iniciar sesión: el catálogo y, si está definida, la de UTN_AULA_BASE_URL.
|
|
@@ -17,10 +17,23 @@ export class InvalidCredentialsError extends Error {
|
|
|
17
17
|
}
|
|
18
18
|
const MAX_REDIRECTS = 20;
|
|
19
19
|
const TIMEOUT_MS = 30_000;
|
|
20
|
+
const PROVIDERS = {
|
|
21
|
+
keycloak: {
|
|
22
|
+
link: /href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/,
|
|
23
|
+
submit: submitKeycloakForm,
|
|
24
|
+
loginError: extractLoginError,
|
|
25
|
+
},
|
|
26
|
+
microsoft: {
|
|
27
|
+
link: /href="([^"]*\/auth\/oidc\/[^"]*)"/,
|
|
28
|
+
submit: submitMicrosoftForm,
|
|
29
|
+
loginError: microsoftLoginError,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
20
32
|
/**
|
|
21
33
|
* Obtiene un token del web service. Dos estrategias según la regional:
|
|
22
|
-
* - Regionales con SSO (Keycloak,
|
|
23
|
-
* SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en
|
|
34
|
+
* - Regionales con SSO (Keycloak como FRBA, Office 365 como FRH): flujo de launch.php, que usa la
|
|
35
|
+
* sesión del SSO. Si el SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en
|
|
36
|
+
* el SSO.
|
|
24
37
|
* - Regionales con login directo de Moodle (ej. FRT): primero `login/token.php` (no depende del
|
|
25
38
|
* plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
|
|
26
39
|
* Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
|
|
@@ -29,10 +42,10 @@ const TIMEOUT_MS = 30_000;
|
|
|
29
42
|
export async function fetchToken(regional, credentials) {
|
|
30
43
|
if (regional.support === "unsupported")
|
|
31
44
|
throw new UnsupportedRegionalError(regional);
|
|
32
|
-
const { baseUrl,
|
|
45
|
+
const { baseUrl, sso } = regional;
|
|
33
46
|
const jar = new CookieJar();
|
|
34
47
|
// Regionales con login directo: intentamos token.php primero (no depende de launch.php).
|
|
35
|
-
if (!
|
|
48
|
+
if (!sso) {
|
|
36
49
|
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
37
50
|
if (viaTokenPhp)
|
|
38
51
|
return viaTokenPhp;
|
|
@@ -42,21 +55,22 @@ export async function fetchToken(regional, credentials) {
|
|
|
42
55
|
let landing = await follow(jar, launchUrl);
|
|
43
56
|
if ("token" in landing)
|
|
44
57
|
return landing.token;
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
const provider = sso && PROVIDERS[sso.provider];
|
|
59
|
+
const ssoHref = provider && landing.html.match(provider.link)?.[1];
|
|
60
|
+
if (sso && provider && ssoHref) {
|
|
61
|
+
landing = await follow(jar, new URL(decodeHtml(ssoHref), landing.url).toString());
|
|
48
62
|
if ("token" in landing)
|
|
49
63
|
return landing.token;
|
|
50
|
-
if (
|
|
51
|
-
landing = await
|
|
64
|
+
if (new URL(landing.url).host === sso.host) {
|
|
65
|
+
landing = await provider.submit(jar, landing, credentials);
|
|
52
66
|
if ("token" in landing)
|
|
53
67
|
return landing.token;
|
|
54
|
-
if (
|
|
68
|
+
if (new URL(landing.url).host === sso.host) {
|
|
55
69
|
// Aspirantes del ingreso y algunas tecnicaturas no tienen cuenta en el SSO: entran con el login de Moodle.
|
|
56
70
|
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
57
71
|
if (viaTokenPhp)
|
|
58
72
|
return viaTokenPhp;
|
|
59
|
-
throw new InvalidCredentialsError(
|
|
73
|
+
throw new InvalidCredentialsError(provider.loginError(landing.html));
|
|
60
74
|
}
|
|
61
75
|
}
|
|
62
76
|
}
|
|
@@ -79,13 +93,144 @@ export async function fetchToken(regional, credentials) {
|
|
|
79
93
|
landing = await follow(jar, launchUrl);
|
|
80
94
|
if ("token" in landing)
|
|
81
95
|
return landing.token;
|
|
82
|
-
if (!
|
|
96
|
+
if (!sso) {
|
|
83
97
|
const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
|
|
84
98
|
if (viaTokenPhp)
|
|
85
99
|
return viaTokenPhp;
|
|
86
100
|
}
|
|
87
101
|
throw unexpected(landing.url);
|
|
88
102
|
}
|
|
103
|
+
export class MfaRequiredError extends Error {
|
|
104
|
+
constructor(reason) {
|
|
105
|
+
super("Tu cuenta de Microsoft pide un segundo factor (código, app o similar), que UTN-MCP todavía no puede " +
|
|
106
|
+
"completar solo." + (reason ? ` Microsoft dijo: ${reason}` : ""));
|
|
107
|
+
this.name = "MfaRequiredError";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Cantidad máxima de pasos de Microsoft entre la contraseña y la vuelta al aula (KMSI y similares). */
|
|
111
|
+
const MAX_MICROSOFT_STEPS = 6;
|
|
112
|
+
/**
|
|
113
|
+
* Formulario de Office 365 (Microsoft Entra ID). No es un `<form>`: los datos están en el `$Config` de
|
|
114
|
+
* la página. Después de la contraseña puede venir "¿Mantener la sesión iniciada?" antes del formulario
|
|
115
|
+
* que vuelve al aula con el `code`. No completa MFA ni logins federados (ADFS).
|
|
116
|
+
*/
|
|
117
|
+
async function submitMicrosoftForm(jar, page, { username, password }) {
|
|
118
|
+
const ssoHost = new URL(page.url).host;
|
|
119
|
+
const cfg = parseAadConfig(page.html);
|
|
120
|
+
if (!cfg?.urlPost || !cfg.sFT) {
|
|
121
|
+
throw new Error("No se encontró el formulario de Microsoft; puede que haya cambiado o que el login sea federado.");
|
|
122
|
+
}
|
|
123
|
+
let landing = await follow(jar, new URL(cfg.urlPost, page.url).toString(), {
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: new URLSearchParams({
|
|
126
|
+
login: username,
|
|
127
|
+
loginfmt: username,
|
|
128
|
+
passwd: password,
|
|
129
|
+
type: "11",
|
|
130
|
+
LoginOptions: "3",
|
|
131
|
+
flowToken: cfg.sFT,
|
|
132
|
+
ctx: cfg.sCtx ?? "",
|
|
133
|
+
canary: cfg.canary ?? "",
|
|
134
|
+
i13: "0",
|
|
135
|
+
ps: "2",
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
for (let i = 0; i < MAX_MICROSOFT_STEPS; i++) {
|
|
139
|
+
if ("token" in landing)
|
|
140
|
+
return landing;
|
|
141
|
+
const form = findPostForm(landing.html);
|
|
142
|
+
const action = form && new URL(form.action, landing.url).toString();
|
|
143
|
+
if (form && action && new URL(action).host !== ssoHost) {
|
|
144
|
+
return follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
|
|
145
|
+
}
|
|
146
|
+
const step = parseAadConfig(landing.html);
|
|
147
|
+
if (step?.arrUserProofs?.length)
|
|
148
|
+
throw new MfaRequiredError(step.strServiceExceptionMessage);
|
|
149
|
+
if (step?.sErrorCode)
|
|
150
|
+
return landing;
|
|
151
|
+
if (step?.urlPost) {
|
|
152
|
+
landing = await follow(jar, new URL(step.urlPost, landing.url).toString(), {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: new URLSearchParams({
|
|
155
|
+
LoginOptions: "1",
|
|
156
|
+
type: "28",
|
|
157
|
+
ctx: step.sCtx ?? "",
|
|
158
|
+
flowToken: step.sFT ?? "",
|
|
159
|
+
canary: step.canary ?? "",
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (form && action) {
|
|
165
|
+
landing = await follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
throw unexpected(landing.url);
|
|
169
|
+
}
|
|
170
|
+
throw new Error("Demasiados pasos en el login de Microsoft.");
|
|
171
|
+
}
|
|
172
|
+
/** El motivo del rechazo en una página de Microsoft (ej. AADSTS50126: usuario o contraseña incorrectos). */
|
|
173
|
+
function microsoftLoginError(html) {
|
|
174
|
+
const cfg = parseAadConfig(html);
|
|
175
|
+
if (!cfg?.sErrorCode)
|
|
176
|
+
return undefined;
|
|
177
|
+
return cfg.strServiceExceptionMessage || `AADSTS${cfg.sErrorCode}`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Extrae el objeto `$Config={...}` que Microsoft embebe en sus páginas de login, recorriendo las
|
|
181
|
+
* llaves a mano (el objeto es grande y una regex no lo acota bien). Devuelve null si no está.
|
|
182
|
+
*/
|
|
183
|
+
function parseAadConfig(html) {
|
|
184
|
+
const at = html.indexOf("$Config=");
|
|
185
|
+
if (at < 0)
|
|
186
|
+
return null;
|
|
187
|
+
const start = html.indexOf("{", at);
|
|
188
|
+
if (start < 0)
|
|
189
|
+
return null;
|
|
190
|
+
let depth = 0;
|
|
191
|
+
let inStr = false;
|
|
192
|
+
let esc = false;
|
|
193
|
+
for (let i = start; i < html.length; i++) {
|
|
194
|
+
const c = html[i];
|
|
195
|
+
if (inStr) {
|
|
196
|
+
if (esc)
|
|
197
|
+
esc = false;
|
|
198
|
+
else if (c === "\\")
|
|
199
|
+
esc = true;
|
|
200
|
+
else if (c === '"')
|
|
201
|
+
inStr = false;
|
|
202
|
+
}
|
|
203
|
+
else if (c === '"')
|
|
204
|
+
inStr = true;
|
|
205
|
+
else if (c === "{")
|
|
206
|
+
depth++;
|
|
207
|
+
else if (c === "}" && --depth === 0) {
|
|
208
|
+
try {
|
|
209
|
+
return JSON.parse(html.slice(start, i + 1));
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/** El primer `<form method="post">` de la página: su `action` y sus inputs ocultos. */
|
|
219
|
+
function findPostForm(html) {
|
|
220
|
+
const m = html.match(/<form\b[^>]*>([\s\S]*?)<\/form>/i);
|
|
221
|
+
if (!m || !/method\s*=\s*["']?post/i.test(m[0]))
|
|
222
|
+
return null;
|
|
223
|
+
const action = m[0].match(/action\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
224
|
+
if (!action)
|
|
225
|
+
return null;
|
|
226
|
+
const inputs = {};
|
|
227
|
+
for (const input of m[1].matchAll(/<input\b[^>]*>/gi)) {
|
|
228
|
+
const name = input[0].match(/name\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
229
|
+
if (name)
|
|
230
|
+
inputs[name] = decodeHtml(input[0].match(/value\s*=\s*["']([^"']*)["']/i)?.[1] ?? "");
|
|
231
|
+
}
|
|
232
|
+
return { action: decodeHtml(action), inputs };
|
|
233
|
+
}
|
|
89
234
|
/**
|
|
90
235
|
* Pide el token a /login/token.php (Moodle estándar). Devuelve null si el aula no lo expone
|
|
91
236
|
* (no contesta JSON), para seguir con launch.php; si contesta con un error, lo propaga.
|
|
@@ -113,14 +258,20 @@ async function tryTokenPhp(baseUrl, credentials) {
|
|
|
113
258
|
if (data.errorcode === "invalidlogin" || /invalid login/i.test(data.error ?? "")) {
|
|
114
259
|
throw new InvalidCredentialsError(data.error);
|
|
115
260
|
}
|
|
261
|
+
// Hay aulas (ej. la TUP) a las que se les rompe un plugin de autenticación con cada login
|
|
262
|
+
// rechazado: contestan `generalexceptionmessage`, y el formulario web muestra la misma excepción.
|
|
263
|
+
if (data.errorcode === "generalexceptionmessage") {
|
|
264
|
+
throw new InvalidCredentialsError(`${data.error} (es un error interno del aula, que en algunas aparece cuando el usuario o la contraseña están mal)`);
|
|
265
|
+
}
|
|
116
266
|
// El mismo servicio lo usa launch.php: si token.php lo rechaza, seguir no sirve.
|
|
117
267
|
throw new MoodleError(`El aula no dio el token (${data.errorcode ?? res.status}): ${data.error ?? "respuesta sin token"}. ` +
|
|
118
268
|
"Si dice que el servicio web no está disponible, lo tiene que habilitar el admin del aula.", data.errorcode);
|
|
119
269
|
}
|
|
120
|
-
|
|
270
|
+
/** Formulario de Keycloak: el `action` viene en el `loginAction` del JSON que arma la página. */
|
|
271
|
+
async function submitKeycloakForm(jar, { html }, { username, password }) {
|
|
121
272
|
const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
|
|
122
273
|
if (!action)
|
|
123
|
-
throw new Error("No se encontró el formulario
|
|
274
|
+
throw new Error("No se encontró el formulario de Keycloak; puede que haya cambiado.");
|
|
124
275
|
const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
|
|
125
276
|
return follow(jar, JSON.parse(action), { method: "POST", body });
|
|
126
277
|
}
|
|
@@ -12,7 +12,8 @@ import { availableRegionals, defaultRegional } from "./client/regionals.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export async function runLogin(http) {
|
|
14
14
|
const regional = await askRegional();
|
|
15
|
-
const
|
|
15
|
+
const prompt = regional.sso?.provider === "microsoft" ? "Mail de Office 365: " : "Usuario de la UTN: ";
|
|
16
|
+
const username = (await ask(prompt)).trim();
|
|
16
17
|
if (!username)
|
|
17
18
|
throw new Error("Falta el usuario.");
|
|
18
19
|
const password = await askHidden("Contraseña: ");
|
package/build/oauth/admin.js
CHANGED
|
@@ -77,8 +77,8 @@ export function mountAdmin(app, store, secret) {
|
|
|
77
77
|
});
|
|
78
78
|
/**
|
|
79
79
|
* `{ regional, usuario, contrasena }` → entra al aula como el login de OAuth: crea el usuario si no
|
|
80
|
-
* existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email
|
|
81
|
-
* no entra, `{ error, motivo }`.
|
|
80
|
+
* existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email,
|
|
81
|
+
* primero? }`, con `primero` si fue el primero en verificar la regional; si no entra, `{ error, motivo }`.
|
|
82
82
|
*/
|
|
83
83
|
app.post("/admin/verificar", requireSecret, json, async (req, res) => {
|
|
84
84
|
const id = typeof req.body?.regional === "string" ? req.body.regional : "";
|
|
@@ -97,7 +97,12 @@ export function mountAdmin(app, store, secret) {
|
|
|
97
97
|
try {
|
|
98
98
|
const account = await linkAulaAccount(store, regional, { username, password });
|
|
99
99
|
logger.info(`Admin: ${account.username} (${regional.id}) vinculó su aula.`);
|
|
100
|
-
res.json({
|
|
100
|
+
res.json({
|
|
101
|
+
usuario: account.username,
|
|
102
|
+
nombre: account.fullname,
|
|
103
|
+
email: account.email ?? undefined,
|
|
104
|
+
primero: account.firstVerifier || undefined,
|
|
105
|
+
});
|
|
101
106
|
}
|
|
102
107
|
catch (e) {
|
|
103
108
|
if (e instanceof InvalidCredentialsError) {
|
package/build/server.js
CHANGED
|
@@ -7,7 +7,7 @@ import { registerAulaVirtualTools } from "./aula-virtual/tools.js";
|
|
|
7
7
|
export function createServer(aula, files) {
|
|
8
8
|
const server = new McpServer({
|
|
9
9
|
name: "UTN-MCP",
|
|
10
|
-
version: "0.8.
|
|
10
|
+
version: "0.8.1",
|
|
11
11
|
});
|
|
12
12
|
registerAulaVirtualTools(server, aula, files);
|
|
13
13
|
return server;
|
|
@@ -143,9 +143,10 @@ export class SqliteStore {
|
|
|
143
143
|
return rows.map((r) => r.regional);
|
|
144
144
|
}
|
|
145
145
|
async markRegionalVerified(regional) {
|
|
146
|
-
this.db
|
|
146
|
+
const { changes } = this.db
|
|
147
147
|
.prepare("INSERT INTO verified_regionals (regional, verified_at) VALUES (?, ?) ON CONFLICT DO NOTHING")
|
|
148
148
|
.run(regional, now());
|
|
149
|
+
return Number(changes) > 0;
|
|
149
150
|
}
|
|
150
151
|
async saveClient(client) {
|
|
151
152
|
this.db
|
package/package.json
CHANGED