utn-mcp 0.8.0 → 0.8.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 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 (Keycloak), como FRBA, usa el SSO; si no, el login directo de Moodle.
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. Si tu cuenta de Office 365 pide segundo factor (MFA), el login te pregunta con qué método seguir y te pide el código o que apruebes en el celular. Como el servidor renueva la sesión sin vos delante, con MFA te va a pedir que vuelvas a iniciar sesión cuando venza (dura semanas).
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 usa Keycloak
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
 
@@ -6,9 +6,10 @@ import { AULA_PLATFORM } from "./stored-session.js";
6
6
  * guarda la sesión (el token). La contraseña se usa solo acá: no se guarda en ningún lado.
7
7
  * Lo usan `utn-mcp login --http`, el login por OAuth de la versión hosteada y `/admin/verificar`.
8
8
  * `regional` tiene que venir con su soporte en este servidor (`availableRegionals` con las verificadas).
9
+ * `secondFactor` contesta el MFA de Microsoft si la cuenta lo pide.
9
10
  */
10
- export async function linkAulaAccount(store, regional, credentials) {
11
- const token = await fetchToken(regional, credentials);
11
+ export async function linkAulaAccount(store, regional, credentials, secondFactor) {
12
+ const token = await fetchToken(regional, credentials, secondFactor);
12
13
  // El token recién emitido: si falla, no hay con qué renovarlo.
13
14
  const fresh = {
14
15
  getRegional: async () => regional,
@@ -20,14 +21,15 @@ export async function linkAulaAccount(store, regional, credentials) {
20
21
  const own = await getOwnUser(fresh);
21
22
  // La primera vez que alguien entra a una regional sin verificar se prueban también los cursos: si
22
23
  // anda, queda verificada para todos los de este servidor.
24
+ let firstVerifier = false;
23
25
  if (regional.support === "candidate") {
24
26
  await getUserCourses(fresh);
25
- await store.markRegionalVerified(regional.id);
27
+ firstVerifier = await store.markRegionalVerified(regional.id);
26
28
  }
27
29
  // El de Moodle, en minúscula, igual que en `users`: es con lo que se compara al renovar.
28
30
  const username = own.username.toLowerCase();
29
31
  const userId = await store.findOrCreateUser(regional.id, username, own.email ?? undefined);
30
32
  const data = { username, token };
31
33
  await store.saveSession(userId, AULA_PLATFORM, JSON.stringify(data));
32
- return { userId, regional, username, fullname: own.fullname, email: own.email };
34
+ return { userId, regional, username, fullname: own.fullname, email: own.email, firstVerifier };
33
35
  }
@@ -31,12 +31,12 @@ export class LocalSession {
31
31
  });
32
32
  return this.refreshing;
33
33
  }
34
- /** Obtiene un token nuevo y lo guarda. */
35
- async login(regional, credentials) {
34
+ /** Obtiene un token nuevo y lo guarda. Sin `secondFactor` (al renovar solo), una cuenta con MFA no entra. */
35
+ async login(regional, credentials, secondFactor) {
36
36
  if (!credentials)
37
37
  throw new LoginRequiredError();
38
38
  logger.info(`Obteniendo token del aula virtual de ${regional.name}...`);
39
- const token = await fetchToken(regional, credentials);
39
+ const token = await fetchToken(regional, credentials, secondFactor);
40
40
  await this.write({ token, obtainedAt: new Date().toISOString(), regional: regional.id });
41
41
  return token;
42
42
  }
@@ -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
- ssoHost: "auth.frba.utn.edu.ar",
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
- // Office 365.
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 no tiene el servicio habilitado y entra con Office 365.
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
- // Office 365.
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 usa Keycloak).");
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
- return { id: host, name: host, baseUrl, ssoHost: process.env.UTN_SSO_HOST?.trim() || undefined, support: "verified" };
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.
@@ -1,4 +1,5 @@
1
1
  import { randomInt } from "node:crypto";
2
+ import { setTimeout as sleep } from "node:timers/promises";
2
3
  import { CookieJar } from "tough-cookie";
3
4
  import { UnsupportedRegionalError } from "./regionals.js";
4
5
  import { MoodleError } from "./moodle-api.js";
@@ -17,22 +18,37 @@ export class InvalidCredentialsError extends Error {
17
18
  }
18
19
  const MAX_REDIRECTS = 20;
19
20
  const TIMEOUT_MS = 30_000;
21
+ const PROVIDERS = {
22
+ keycloak: {
23
+ link: /href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/,
24
+ submit: submitKeycloakForm,
25
+ loginError: extractLoginError,
26
+ },
27
+ microsoft: {
28
+ link: /href="([^"]*\/auth\/oidc\/[^"]*)"/,
29
+ submit: submitMicrosoftForm,
30
+ loginError: microsoftLoginError,
31
+ },
32
+ };
20
33
  /**
21
34
  * Obtiene un token del web service. Dos estrategias según la regional:
22
- * - Regionales con SSO (Keycloak, ej. FRBA): flujo de launch.php, que usa la sesión del SSO. Si el
23
- * SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en el SSO.
35
+ * - Regionales con SSO (Keycloak como FRBA, Office 365 como FRH): flujo de launch.php, que usa la
36
+ * sesión del SSO. Si el SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en
37
+ * el SSO.
24
38
  * - Regionales con login directo de Moodle (ej. FRT): primero `login/token.php` (no depende del
25
39
  * plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
26
40
  * Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
27
41
  * horas y el token, semanas, así que no sirven para renovarlo.
42
+ * `secondFactor` contesta el MFA de Microsoft; sin él (las renovaciones solas), una cuenta con MFA da
43
+ * `MfaRequiredError`.
28
44
  */
29
- export async function fetchToken(regional, credentials) {
45
+ export async function fetchToken(regional, credentials, secondFactor) {
30
46
  if (regional.support === "unsupported")
31
47
  throw new UnsupportedRegionalError(regional);
32
- const { baseUrl, ssoHost } = regional;
48
+ const { baseUrl, sso } = regional;
33
49
  const jar = new CookieJar();
34
50
  // Regionales con login directo: intentamos token.php primero (no depende de launch.php).
35
- if (!ssoHost) {
51
+ if (!sso) {
36
52
  const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
37
53
  if (viaTokenPhp)
38
54
  return viaTokenPhp;
@@ -42,21 +58,22 @@ export async function fetchToken(regional, credentials) {
42
58
  let landing = await follow(jar, launchUrl);
43
59
  if ("token" in landing)
44
60
  return landing.token;
45
- const oauthUrl = landing.html.match(/href="([^"]*\/auth\/oauth2\/login\.php[^"]*)"/)?.[1];
46
- if (oauthUrl) {
47
- landing = await follow(jar, decodeHtml(oauthUrl));
61
+ const ssoProvider = sso ? PROVIDERS[sso.provider] : undefined;
62
+ const ssoHref = ssoProvider && landing.html.match(ssoProvider.link)?.[1];
63
+ if (sso && ssoProvider && ssoHref) {
64
+ landing = await follow(jar, new URL(decodeHtml(ssoHref), landing.url).toString());
48
65
  if ("token" in landing)
49
66
  return landing.token;
50
- if (ssoHost && new URL(landing.url).host === ssoHost) {
51
- landing = await submitSsoForm(jar, landing.html, credentials);
67
+ if (new URL(landing.url).host === sso.host) {
68
+ landing = await ssoProvider.submit(jar, landing, credentials, secondFactor);
52
69
  if ("token" in landing)
53
70
  return landing.token;
54
- if (ssoHost && new URL(landing.url).host === ssoHost) {
71
+ if (new URL(landing.url).host === sso.host) {
55
72
  // Aspirantes del ingreso y algunas tecnicaturas no tienen cuenta en el SSO: entran con el login de Moodle.
56
73
  const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
57
74
  if (viaTokenPhp)
58
75
  return viaTokenPhp;
59
- throw new InvalidCredentialsError(extractLoginError(landing.html));
76
+ throw new InvalidCredentialsError(ssoProvider.loginError(landing.html));
60
77
  }
61
78
  }
62
79
  }
@@ -79,13 +96,268 @@ export async function fetchToken(regional, credentials) {
79
96
  landing = await follow(jar, launchUrl);
80
97
  if ("token" in landing)
81
98
  return landing.token;
82
- if (!ssoHost) {
99
+ if (!sso) {
83
100
  const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
84
101
  if (viaTokenPhp)
85
102
  return viaTokenPhp;
86
103
  }
87
104
  throw unexpected(landing.url);
88
105
  }
106
+ /** La cuenta pide MFA y no hay quién lo conteste: pasa al renovar solo, sin el usuario delante. */
107
+ export class MfaRequiredError extends LoginRequiredError {
108
+ constructor() {
109
+ super("Tu cuenta de Microsoft pide un segundo factor (MFA), que solo se completa al iniciar sesión: " +
110
+ "corré `utn-mcp login`.");
111
+ this.name = "MfaRequiredError";
112
+ }
113
+ }
114
+ /** El segundo factor no se pudo completar; el mensaje dice por qué y se le puede mostrar al usuario. */
115
+ export class MfaError extends Error {
116
+ constructor(message) {
117
+ super(message);
118
+ this.name = "MfaError";
119
+ }
120
+ }
121
+ /** Los `authMethodId` que se completan sin navegador: los demás (FIDO, Windows Hello) necesitan uno. */
122
+ const MFA_METHODS = {
123
+ PhoneAppNotification: { kind: "approve", label: "Notificación de Microsoft Authenticator" },
124
+ CompanionAppsNotification: { kind: "approve", label: "Notificación de Microsoft Authenticator" },
125
+ PhoneAppOTP: { kind: "code", label: "Código de Microsoft Authenticator" },
126
+ OneWaySMS: { kind: "code", label: "Código por SMS" },
127
+ TwoWayVoiceMobile: { kind: "approve", label: "Llamada al celular" },
128
+ TwoWayVoiceAlternateMobile: { kind: "approve", label: "Llamada al celular alternativo" },
129
+ TwoWayVoiceOffice: { kind: "approve", label: "Llamada al teléfono de la oficina" },
130
+ };
131
+ /** Intentos de código antes de cortar: Microsoft también bloquea después de varios. */
132
+ const MAX_CODE_ATTEMPTS = 3;
133
+ /** Cada cuánto se pregunta si ya se aprobó, en milisegundos, y cuántas veces (un minuto en total). */
134
+ const APPROVE_POLL_MS = 2_000;
135
+ const APPROVE_POLLS = 30;
136
+ /** Cantidad máxima de pasos de Microsoft entre la contraseña y la vuelta al aula (MFA, KMSI y similares). */
137
+ const MAX_MICROSOFT_STEPS = 8;
138
+ /**
139
+ * Formulario de Office 365 (Microsoft Entra ID). No es un `<form>`: los datos están en el `$Config` de
140
+ * la página. Después de la contraseña pueden venir el segundo factor y "¿Mantener la sesión iniciada?"
141
+ * antes del formulario que vuelve al aula con el `code`. No completa logins federados (ADFS).
142
+ */
143
+ async function submitMicrosoftForm(jar, page, { username, password }, secondFactor) {
144
+ const ssoHost = new URL(page.url).host;
145
+ const cfg = parseAadConfig(page.html);
146
+ if (!cfg?.urlPost || !cfg.sFT) {
147
+ throw new Error("No se encontró el formulario de Microsoft; puede que haya cambiado o que el login sea federado.");
148
+ }
149
+ let landing = await follow(jar, new URL(cfg.urlPost, page.url).toString(), {
150
+ method: "POST",
151
+ body: new URLSearchParams({
152
+ login: username,
153
+ loginfmt: username,
154
+ passwd: password,
155
+ type: "11",
156
+ LoginOptions: "3",
157
+ flowToken: cfg.sFT,
158
+ ctx: cfg.sCtx ?? "",
159
+ canary: cfg.canary ?? "",
160
+ i13: "0",
161
+ ps: "2",
162
+ }),
163
+ });
164
+ for (let i = 0; i < MAX_MICROSOFT_STEPS; i++) {
165
+ if ("token" in landing)
166
+ return landing;
167
+ const form = findPostForm(landing.html);
168
+ const action = form && new URL(form.action, landing.url).toString();
169
+ if (form && action && new URL(action).host !== ssoHost) {
170
+ return follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
171
+ }
172
+ const step = parseAadConfig(landing.html);
173
+ if (step?.arrUserProofs?.length) {
174
+ if (!secondFactor)
175
+ throw new MfaRequiredError();
176
+ landing = await completeMfa(jar, landing, step, username, secondFactor);
177
+ continue;
178
+ }
179
+ if (step?.sErrorCode)
180
+ return landing;
181
+ if (step?.urlPost) {
182
+ landing = await follow(jar, new URL(step.urlPost, landing.url).toString(), {
183
+ method: "POST",
184
+ body: new URLSearchParams({
185
+ LoginOptions: "1",
186
+ type: "28",
187
+ ctx: step.sCtx ?? "",
188
+ flowToken: step.sFT ?? "",
189
+ canary: step.canary ?? "",
190
+ }),
191
+ });
192
+ continue;
193
+ }
194
+ if (form && action) {
195
+ landing = await follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
196
+ continue;
197
+ }
198
+ throw unexpected(landing.url);
199
+ }
200
+ throw new Error("Demasiados pasos en el login de Microsoft.");
201
+ }
202
+ /**
203
+ * El segundo factor de Microsoft, con los mismos endpoints que usa su página: `BeginAuth` lo arranca
204
+ * (manda la notificación, el SMS o la llamada), `EndAuth` lo verifica (con el código, o preguntando si
205
+ * ya se aprobó) y `ProcessAuth` sigue el login, que vuelve a la página siguiente (KMSI o el aula).
206
+ */
207
+ async function completeMfa(jar, page, cfg, username, secondFactor) {
208
+ const proofs = cfg.arrUserProofs ?? [];
209
+ const methods = proofs.flatMap(({ authMethodId: id, display }) => {
210
+ const known = id ? MFA_METHODS[id] : undefined;
211
+ return id && known ? [{ id, kind: known.kind, label: display ? `${known.label} · ${display}` : known.label }] : [];
212
+ });
213
+ if (!methods.length) {
214
+ const ids = proofs.map((p) => p.authMethodId).join(", ");
215
+ throw new MfaError(`Tu cuenta de Microsoft solo tiene métodos de segundo factor que necesitan el navegador (${ids}). ` +
216
+ "Agregá Microsoft Authenticator o un teléfono en https://mysignins.microsoft.com/security-info y volvé a intentar.");
217
+ }
218
+ if (!cfg.urlBeginAuth || !cfg.urlEndAuth || !cfg.urlPost) {
219
+ throw new Error("No se encontró el formulario del segundo factor de Microsoft; puede que haya cambiado.");
220
+ }
221
+ const preferred = proofs.find((p) => p.isDefault)?.authMethodId;
222
+ methods.sort((a, b) => Number(b.id === preferred) - Number(a.id === preferred));
223
+ const chosenId = methods.length > 1 ? await secondFactor({ step: "choose", methods }) : methods[0].id;
224
+ const method = methods.find((m) => m.id === chosenId);
225
+ if (!method)
226
+ throw new MfaError("Ese método de segundo factor no está entre los de tu cuenta.");
227
+ const sasUrl = (url) => new URL(url, page.url).toString();
228
+ const begin = await postSas(jar, sasUrl(cfg.urlBeginAuth), {
229
+ AuthMethodId: method.id,
230
+ Method: "BeginAuth",
231
+ ctx: cfg.sCtx ?? "",
232
+ flowToken: cfg.sFT ?? "",
233
+ });
234
+ if (!begin.Success)
235
+ throw new MfaError(`Microsoft no pudo arrancar el segundo factor (${begin.Message || begin.ResultValue}).`);
236
+ const endAuth = (extra) => postSas(jar, sasUrl(cfg.urlEndAuth), {
237
+ Method: "EndAuth",
238
+ AuthMethodId: method.id,
239
+ SessionId: begin.SessionId,
240
+ FlowToken: begin.FlowToken,
241
+ Ctx: begin.Ctx,
242
+ ...extra,
243
+ });
244
+ let end;
245
+ let otc = "";
246
+ if (method.kind === "code") {
247
+ for (let attempt = 0; attempt < MAX_CODE_ATTEMPTS && !end?.Success; attempt++) {
248
+ otc = (await secondFactor({ step: "code", method, retry: attempt > 0 })).replace(/\s/g, "");
249
+ end = await endAuth({ AdditionalAuthData: otc, PollCount: 1 });
250
+ if (!end.Success && !/incorrect|wrongcode/i.test(end.ResultValue ?? ""))
251
+ break;
252
+ }
253
+ }
254
+ else {
255
+ await secondFactor({ step: "approve", method, number: begin.Entropy || undefined });
256
+ for (let poll = 1; poll <= APPROVE_POLLS; poll++) {
257
+ end = await endAuth({ PollCount: poll });
258
+ if (end.Success || end.ResultValue !== "AuthenticationPending")
259
+ break;
260
+ await sleep(APPROVE_POLL_MS);
261
+ }
262
+ }
263
+ if (!end?.Success) {
264
+ const reason = end?.ResultValue === "AuthenticationPending" ? "no llegó la aprobación a tiempo" : end?.Message || end?.ResultValue;
265
+ throw new MfaError(`Microsoft no aceptó el segundo factor (${reason}). Volvé a iniciar sesión para probar de nuevo.`);
266
+ }
267
+ return follow(jar, sasUrl(cfg.urlPost), {
268
+ method: "POST",
269
+ body: new URLSearchParams({
270
+ request: end.Ctx ?? begin.Ctx ?? "",
271
+ mfaAuthMethod: method.id,
272
+ flowToken: end.FlowToken ?? begin.FlowToken ?? "",
273
+ canary: cfg.canary ?? "",
274
+ otc,
275
+ login: username,
276
+ hideSmsInMfaProofs: "false",
277
+ }),
278
+ });
279
+ }
280
+ /** POST JSON a `SAS/BeginAuth` o `SAS/EndAuth`, con las cookies del login. */
281
+ async function postSas(jar, url, body) {
282
+ const cookie = await jar.getCookieString(url);
283
+ const res = await fetch(url, {
284
+ method: "POST",
285
+ headers: { "content-type": "application/json", accept: "application/json", ...(cookie ? { cookie } : {}) },
286
+ body: JSON.stringify(body),
287
+ signal: AbortSignal.timeout(TIMEOUT_MS),
288
+ });
289
+ for (const setCookie of res.headers.getSetCookie()) {
290
+ await jar.setCookie(setCookie, url, { ignoreError: true });
291
+ }
292
+ try {
293
+ return (await res.json());
294
+ }
295
+ catch {
296
+ throw new Error(`Respuesta inesperada del segundo factor de Microsoft (${res.status}).`);
297
+ }
298
+ }
299
+ /** El motivo del rechazo en una página de Microsoft (ej. AADSTS50126: usuario o contraseña incorrectos). */
300
+ function microsoftLoginError(html) {
301
+ const cfg = parseAadConfig(html);
302
+ if (!cfg?.sErrorCode)
303
+ return undefined;
304
+ return cfg.strServiceExceptionMessage || `AADSTS${cfg.sErrorCode}`;
305
+ }
306
+ /**
307
+ * Extrae el objeto `$Config={...}` que Microsoft embebe en sus páginas de login, recorriendo las
308
+ * llaves a mano (el objeto es grande y una regex no lo acota bien). Devuelve null si no está.
309
+ */
310
+ function parseAadConfig(html) {
311
+ const at = html.indexOf("$Config=");
312
+ if (at < 0)
313
+ return null;
314
+ const start = html.indexOf("{", at);
315
+ if (start < 0)
316
+ return null;
317
+ let depth = 0;
318
+ let inStr = false;
319
+ let esc = false;
320
+ for (let i = start; i < html.length; i++) {
321
+ const c = html[i];
322
+ if (inStr) {
323
+ if (esc)
324
+ esc = false;
325
+ else if (c === "\\")
326
+ esc = true;
327
+ else if (c === '"')
328
+ inStr = false;
329
+ }
330
+ else if (c === '"')
331
+ inStr = true;
332
+ else if (c === "{")
333
+ depth++;
334
+ else if (c === "}" && --depth === 0) {
335
+ try {
336
+ return JSON.parse(html.slice(start, i + 1));
337
+ }
338
+ catch {
339
+ return null;
340
+ }
341
+ }
342
+ }
343
+ return null;
344
+ }
345
+ /** El primer `<form method="post">` de la página: su `action` y sus inputs ocultos. */
346
+ function findPostForm(html) {
347
+ const m = html.match(/<form\b[^>]*>([\s\S]*?)<\/form>/i);
348
+ if (!m || !/method\s*=\s*["']?post/i.test(m[0]))
349
+ return null;
350
+ const action = m[0].match(/action\s*=\s*["']([^"']*)["']/i)?.[1];
351
+ if (!action)
352
+ return null;
353
+ const inputs = {};
354
+ for (const input of m[1].matchAll(/<input\b[^>]*>/gi)) {
355
+ const name = input[0].match(/name\s*=\s*["']([^"']*)["']/i)?.[1];
356
+ if (name)
357
+ inputs[name] = decodeHtml(input[0].match(/value\s*=\s*["']([^"']*)["']/i)?.[1] ?? "");
358
+ }
359
+ return { action: decodeHtml(action), inputs };
360
+ }
89
361
  /**
90
362
  * Pide el token a /login/token.php (Moodle estándar). Devuelve null si el aula no lo expone
91
363
  * (no contesta JSON), para seguir con launch.php; si contesta con un error, lo propaga.
@@ -113,14 +385,20 @@ async function tryTokenPhp(baseUrl, credentials) {
113
385
  if (data.errorcode === "invalidlogin" || /invalid login/i.test(data.error ?? "")) {
114
386
  throw new InvalidCredentialsError(data.error);
115
387
  }
388
+ // Hay aulas (ej. la TUP) a las que se les rompe un plugin de autenticación con cada login
389
+ // rechazado: contestan `generalexceptionmessage`, y el formulario web muestra la misma excepción.
390
+ if (data.errorcode === "generalexceptionmessage") {
391
+ 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)`);
392
+ }
116
393
  // El mismo servicio lo usa launch.php: si token.php lo rechaza, seguir no sirve.
117
394
  throw new MoodleError(`El aula no dio el token (${data.errorcode ?? res.status}): ${data.error ?? "respuesta sin token"}. ` +
118
395
  "Si dice que el servicio web no está disponible, lo tiene que habilitar el admin del aula.", data.errorcode);
119
396
  }
120
- async function submitSsoForm(jar, html, { username, password }) {
397
+ /** Formulario de Keycloak: el `action` viene en el `loginAction` del JSON que arma la página. */
398
+ async function submitKeycloakForm(jar, { html }, { username, password }) {
121
399
  const action = html.match(/"loginAction"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
122
400
  if (!action)
123
- throw new Error("No se encontró el formulario del SSO; puede que haya cambiado.");
401
+ throw new Error("No se encontró el formulario de Keycloak; puede que haya cambiado.");
124
402
  const body = new URLSearchParams({ username, password, rememberMe: "on", credentialId: "" });
125
403
  return follow(jar, JSON.parse(action), { method: "POST", body });
126
404
  }
@@ -1,6 +1,6 @@
1
1
  import { loadCredentials } from "./credentials.js";
2
2
  import { defaultRegional, findRegional } from "./regionals.js";
3
- import { fetchToken, LoginRequiredError } from "./sso-login.js";
3
+ import { fetchToken, LoginRequiredError, MfaRequiredError } from "./sso-login.js";
4
4
  /** Con qué nombre guarda el aula su sesión en el store. */
5
5
  export const AULA_PLATFORM = "aula";
6
6
  /**
@@ -47,7 +47,9 @@ export class StoredAulaSession {
47
47
  (credentials.regional ?? defaultRegional().id) === regional.id;
48
48
  if (!credentials || !sameUser)
49
49
  throw this.loginRequired("La sesión del aula venció.");
50
- const token = await fetchToken(regional, credentials);
50
+ const token = await fetchToken(regional, credentials).catch((e) => {
51
+ throw e instanceof MfaRequiredError ? this.loginRequired("Tu cuenta de Microsoft pide un segundo factor (MFA).") : e;
52
+ });
51
53
  // Sin `...stored`: las sesiones guardadas antes traen cookies, que ya no se usan.
52
54
  const data = { username: stored.username, token };
53
55
  await this.store.saveSession(this.userId, AULA_PLATFORM, JSON.stringify(data));
@@ -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 username = (await ask("Usuario de la UTN: ")).trim();
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: ");
@@ -21,7 +22,7 @@ export async function runLogin(http) {
21
22
  if (http)
22
23
  accessToken = await loginToStore(regional, credentials);
23
24
  else
24
- await new LocalSession().login(regional, credentials);
25
+ await new LocalSession().login(regional, credentials, askSecondFactor);
25
26
  // Se guardan recién después de comprobar que el aula las aceptó.
26
27
  if (await saveCredentials(credentials)) {
27
28
  logger.info("Credenciales guardadas en el keychain del sistema.");
@@ -49,9 +50,21 @@ async function askRegional() {
49
50
  const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
50
51
  return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
51
52
  }
53
+ /** Contesta por la terminal el segundo factor de Microsoft, si la cuenta lo pide. */
54
+ const askSecondFactor = async (prompt) => {
55
+ if (prompt.step === "choose") {
56
+ const labels = prompt.methods.map((m) => m.label);
57
+ return prompt.methods[await askChoice("Microsoft pide un segundo factor:", labels)].id;
58
+ }
59
+ if (prompt.step === "code") {
60
+ return ask(prompt.retry ? "El código no era correcto. Probá de nuevo: " : `${prompt.method.label}: `);
61
+ }
62
+ const number = prompt.number ? ` y tocá el número ${prompt.number}` : "";
63
+ return ask(`Aprobá el inicio de sesión en el celular (${prompt.method.label})${number}. Después apretá Enter. `);
64
+ };
52
65
  /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
53
66
  async function loginToStore(regional, credentials) {
54
67
  const store = await SqliteStore.open();
55
- const { userId } = await linkAulaAccount(store, regional, credentials);
68
+ const { userId } = await linkAulaAccount(store, regional, credentials, askSecondFactor);
56
69
  return store.issueToken(userId, { kind: "access", clientId: "local" });
57
70
  }
@@ -2,7 +2,7 @@ import { timingSafeEqual } from "node:crypto";
2
2
  import express from "express";
3
3
  import { linkAulaAccount } from "../aula-virtual/client/link.js";
4
4
  import { availableRegionals } from "../aula-virtual/client/regionals.js";
5
- import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
5
+ import { InvalidCredentialsError, MfaError, MfaRequiredError } from "../aula-virtual/client/sso-login.js";
6
6
  import { AULA_PLATFORM } from "../aula-virtual/client/stored-session.js";
7
7
  import { logger } from "../shared/logger.js";
8
8
  import { RateLimiter } from "./expiring.js";
@@ -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 }`; si
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,12 +97,23 @@ 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({ usuario: account.username, nombre: account.fullname, email: account.email ?? undefined });
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) {
104
109
  return void res.status(401).json({ error: "credenciales", motivo: "El aula rechazó el usuario o la contraseña." });
105
110
  }
111
+ if (e instanceof MfaRequiredError || e instanceof MfaError) {
112
+ return void res.status(422).json({
113
+ error: "mfa",
114
+ motivo: "La cuenta de Microsoft pide un segundo factor (MFA): que entre por la página de login.",
115
+ });
116
+ }
106
117
  logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
107
118
  res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
108
119
  }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Un login que queda esperando el segundo factor entre un request y otro: corre en segundo plano y,
3
+ * cuando le pregunta algo al usuario, `next` lo devuelve para mostrárselo en una página; `answer` le
4
+ * pasa lo que el usuario contestó en el request siguiente.
5
+ */
6
+ export class PendingLogin {
7
+ run;
8
+ waiting = null;
9
+ listeners = [];
10
+ constructor(start) {
11
+ this.run = start((prompt) => new Promise((resolve) => {
12
+ this.waiting = { prompt, resolve };
13
+ for (const listener of this.listeners.splice(0))
14
+ listener(prompt);
15
+ }));
16
+ this.run.catch(() => { });
17
+ }
18
+ /** Espera a que el login termine o le pregunte algo al usuario. */
19
+ next() {
20
+ if (this.waiting)
21
+ return Promise.resolve({ prompt: this.waiting.prompt });
22
+ return Promise.race([
23
+ this.run.then((done) => ({ done })),
24
+ new Promise((resolve) => this.listeners.push((prompt) => resolve({ prompt }))),
25
+ ]);
26
+ }
27
+ /** Contesta lo que está preguntando; si no pregunta nada (un formulario mandado dos veces), no hace nada. */
28
+ answer(value) {
29
+ const waiting = this.waiting;
30
+ this.waiting = null;
31
+ waiting?.resolve(value);
32
+ }
33
+ }
@@ -3,10 +3,11 @@ import express from "express";
3
3
  import { buildOAuthProtectedResourceMetadata, checkResourceAllowed, getOAuthProtectedResourceMetadataUrl, } from "@modelcontextprotocol/server";
4
4
  import { linkAulaAccount } from "../aula-virtual/client/link.js";
5
5
  import { availableRegionals, defaultRegional } from "../aula-virtual/client/regionals.js";
6
- import { InvalidCredentialsError } from "../aula-virtual/client/sso-login.js";
6
+ import { InvalidCredentialsError, MfaError } from "../aula-virtual/client/sso-login.js";
7
7
  import { logger } from "../shared/logger.js";
8
8
  import { escapeHtml, page } from "../shared/pages.js";
9
9
  import { Expiring, RateLimiter } from "./expiring.js";
10
+ import { PendingLogin } from "./pending-login.js";
10
11
  import { describeRedirect, redirectTrust } from "./redirects.js";
11
12
  /** Duración del access token, en segundos: una baja de acceso se aplica a más tardar en este tiempo. */
12
13
  const ACCESS_TTL = 60 * 60;
@@ -49,6 +50,7 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
49
50
  });
50
51
  const pending = new Expiring(PENDING_TTL_MS);
51
52
  const codes = new Expiring(CODE_TTL_MS);
53
+ const secondFactors = new Expiring(PENDING_TTL_MS);
52
54
  const limiter = new RateLimiter(LOGIN_ATTEMPTS, LOGIN_WINDOW_MS);
53
55
  const registerLimiter = new RateLimiter(REGISTER_ATTEMPTS, REGISTER_WINDOW_MS);
54
56
  const form = express.urlencoded({ extended: false, limit: "16kb" });
@@ -130,7 +132,7 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
130
132
  if (!request)
131
133
  return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
132
134
  const list = await regionals();
133
- const retry = (error) => res.type("html").send(loginPage({
135
+ const retry = (res, error) => res.type("html").send(loginPage({
134
136
  action: "/authorize",
135
137
  regionals: list,
136
138
  pendingId: id,
@@ -141,23 +143,24 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
141
143
  error,
142
144
  }));
143
145
  if (request.unknownRedirect && req.body?.acepto !== "si") {
144
- return void retry("Para conectar una app que no es un cliente conocido tenés que aceptar el aviso.");
146
+ return void retry(res, "Para conectar una app que no es un cliente conocido tenés que aceptar el aviso.");
145
147
  }
146
- const account = await login(req, list, retry);
147
- if (!account)
148
- return;
149
- const decision = await access({ regional: account.regional.id, username: account.username, email: account.email });
150
- if (!decision.allowed)
151
- return void deniedPage(res, decision.reason, decision.url);
152
- pending.take(id);
153
- const code = codes.add({
154
- userId: account.userId,
155
- clientId: request.clientId,
156
- redirectUri: request.redirectUri,
157
- codeChallenge: request.codeChallenge,
148
+ await login(req, res, list, retry, async (res, account) => {
149
+ const decision = await access({ regional: account.regional.id, username: account.username, email: account.email });
150
+ if (!decision.allowed)
151
+ return void deniedPage(res, decision.reason, decision.url);
152
+ if (!pending.take(id)) {
153
+ return void errorPage(res, 400, "Pedido vencido", "Pasó demasiado tiempo. Volvé a conectar desde tu app.");
154
+ }
155
+ const code = codes.add({
156
+ userId: account.userId,
157
+ clientId: request.clientId,
158
+ redirectUri: request.redirectUri,
159
+ codeChallenge: request.codeChallenge,
160
+ });
161
+ logger.info(`OAuth: ${account.username} (${account.regional.id}) autorizó a ${request.clientName ?? request.clientId}.`);
162
+ redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
158
163
  });
159
- logger.info(`OAuth: ${account.username} (${account.regional.id}) autorizó a ${request.clientName ?? request.clientId}.`);
160
- redirectWith(res, request.redirectUri, { code, state: request.state, iss: issuer });
161
164
  });
162
165
  app.post("/token", cors, form, express.json({ limit: "16kb" }), async (req, res) => {
163
166
  const body = (req.body ?? {});
@@ -213,54 +216,74 @@ export function mountOAuth(app, { issuer, store, access, sameOrigin, redirects }
213
216
  app.get("/login", async (_req, res) => void res.type("html").send(loginPage({ action: "/login", regionals: await regionals() })));
214
217
  app.post("/login", sameOrigin, form, async (req, res) => {
215
218
  const list = await regionals();
216
- const account = await login(req, list, (error) => res.type("html").send(loginPage({
219
+ const retry = (res, error) => res.type("html").send(loginPage({
217
220
  action: "/login",
218
221
  regionals: list,
219
222
  regional: req.body?.regional,
220
223
  error,
221
- })));
222
- if (!account)
223
- return;
224
- logger.info(`OAuth: ${account.username} (${account.regional.id}) renovó su sesión del aula.`);
225
- res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
224
+ }));
225
+ await login(req, res, list, retry, async (res, account) => {
226
+ logger.info(`OAuth: ${account.username} (${account.regional.id}) renovó su sesión del aula.`);
227
+ res.type("html").send(page("Listo", "<p>Ya renovamos tu sesión del aula. Volvé al chat y pedile al agente que reintente.</p>"));
228
+ });
229
+ });
230
+ app.post("/segundo-factor", sameOrigin, form, async (req, res) => {
231
+ const id = String(req.body?.login ?? "");
232
+ const utnLogin = secondFactors.get(id);
233
+ if (!utnLogin)
234
+ return void errorPage(res, 400, "Login vencido", "Pasó demasiado tiempo. Volvé a iniciar sesión.");
235
+ utnLogin.flow.answer(String(req.body?.respuesta ?? ""));
236
+ await advance(res, utnLogin, id);
226
237
  });
227
238
  /** Entra al aula con lo que mandó el formulario; si falla, vuelve a mostrarlo con el error. */
228
- async function login(req, list, retry) {
239
+ async function login(req, res, list, retry, finish) {
229
240
  if (!limiter.allow(req.ip ?? "desconocida")) {
230
- retry("Demasiados intentos. Esperá unos minutos y probá de nuevo.");
231
- return null;
241
+ return retry(res, "Demasiados intentos. Esperá unos minutos y probá de nuevo.");
232
242
  }
233
243
  const username = String(req.body?.usuario ?? "").trim();
234
244
  const password = String(req.body?.contrasena ?? "");
235
- if (!username || !password) {
236
- retry("Completá usuario y contraseña.");
237
- return null;
238
- }
245
+ if (!username || !password)
246
+ return retry(res, "Completá usuario y contraseña.");
239
247
  const regional = list.find((r) => r.id === String(req.body?.regional ?? ""));
240
- if (!regional) {
241
- retry("Elegí tu regional de la lista.");
242
- return null;
243
- }
248
+ if (!regional)
249
+ return retry(res, "Elegí tu regional de la lista.");
244
250
  if (regional.support === "unsupported") {
245
- retry(`Todavía no hay soporte para ${regional.name}: su aula todavía no funciona con UTN-MCP.`);
246
- return null;
251
+ return retry(res, `Todavía no hay soporte para ${regional.name}: su aula todavía no funciona con UTN-MCP.`);
247
252
  }
253
+ const flow = new PendingLogin((secondFactor) => linkAulaAccount(store, regional, { username, password }, secondFactor));
254
+ await advance(res, { flow, regional, username, retry, finish });
255
+ }
256
+ /**
257
+ * Sigue el login hasta que termina o pide el segundo factor: entonces muestra la página para
258
+ * contestarlo y lo guarda hasta la respuesta. `id` es el de un login que ya estaba esperando.
259
+ */
260
+ async function advance(res, utnLogin, id) {
261
+ const { flow, regional, username, retry, finish } = utnLogin;
248
262
  try {
249
- return await linkAulaAccount(store, regional, { username, password });
263
+ const step = await flow.next();
264
+ if ("prompt" in step) {
265
+ return void res.type("html").send(secondFactorPage(id ?? secondFactors.add(utnLogin), step.prompt));
266
+ }
267
+ if (id)
268
+ secondFactors.take(id);
269
+ await finish(res, step.done);
250
270
  }
251
271
  catch (e) {
272
+ if (id)
273
+ secondFactors.take(id);
252
274
  if (e instanceof InvalidCredentialsError)
253
- retry("La UTN rechazó el usuario o la contraseña.");
275
+ retry(res, "La UTN rechazó el usuario o la contraseña.");
276
+ else if (e instanceof MfaError)
277
+ retry(res, e.message);
254
278
  else if (regional.support === "candidate") {
255
279
  // Nadie la había usado acá: lo más probable es que todavía no ande, no que esté caída.
256
280
  logger.warn(`Regional ${regional.id} sin verificar, falló el login de ${username}: ${e.message}`);
257
- retry(`No pudimos usar el aula de ${regional.name}: puede que todavía no ande con UTN-MCP.`);
281
+ retry(res, `No pudimos usar el aula de ${regional.name}: puede que todavía no ande con UTN-MCP.`);
258
282
  }
259
283
  else {
260
284
  logger.error(`Login de ${username} (${regional.id}): ${e.message}`);
261
- retry("No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
285
+ retry(res, "No pudimos entrar al aula. Puede que esté caída: probá de nuevo en unos minutos.");
262
286
  }
263
- return null;
264
287
  }
265
288
  }
266
289
  return getOAuthProtectedResourceMetadataUrl(new URL(resource));
@@ -299,6 +322,28 @@ function deniedPage(res, reason, url) {
299
322
  const link = url ? `<p><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></p>` : "";
300
323
  res.status(403).type("html").send(page("Sin acceso", `<p>${escapeHtml(reason)}</p>${link}`));
301
324
  }
325
+ /** La página del segundo factor de Microsoft: elegir el método, tipear el código o avisar que ya se aprobó. */
326
+ function secondFactorPage(id, prompt) {
327
+ const hidden = `<input type="hidden" name="login" value="${escapeHtml(id)}">`;
328
+ const formWith = (fields, button) => `<form method="post" action="/segundo-factor">${hidden}${fields}<button type="submit">${button}</button></form>`;
329
+ if (prompt.step === "choose") {
330
+ const options = prompt.methods
331
+ .map((m, i) => `<label class=acepto><input type="radio" name="respuesta" value="${escapeHtml(m.id)}"${i === 0 ? " checked" : ""}>` +
332
+ `<span>${escapeHtml(m.label)}</span></label>`)
333
+ .join("");
334
+ return page("Segundo factor", "<p>Tu cuenta de Microsoft pide un segundo factor. ¿Con cuál seguís?</p>" + formWith(options, "Seguir"));
335
+ }
336
+ if (prompt.step === "code") {
337
+ const error = prompt.retry ? "<p class=error>El código no era correcto. Probá de nuevo.</p>" : "";
338
+ const field = `<label>${escapeHtml(prompt.method.label)}<input name="respuesta" inputmode="numeric" ` +
339
+ `autocomplete="one-time-code" autocapitalize="none" required></label>`;
340
+ return page("Segundo factor", error + formWith(field, "Verificar"));
341
+ }
342
+ const number = prompt.number ? `<p>Cuando te lo pida, tocá este número:</p><p class=numero>${prompt.number}</p>` : "";
343
+ return page("Segundo factor", `<p>Aprobá el inicio de sesión en el celular (${escapeHtml(prompt.method.label)}).</p>` +
344
+ number +
345
+ formWith(`<input type="hidden" name="respuesta" value="ok">`, "Ya aprobé"));
346
+ }
302
347
  function loginPage(o) {
303
348
  const where = o.redirectUri ? `<strong>${escapeHtml(describeRedirect(o.redirectUri))}</strong>` : "";
304
349
  const destination = where && !o.unknownRedirect ? ` Al terminar vas a volver a ${where}: si no es la app que estás conectando, no sigas.` : "";
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.0",
10
+ version: "0.8.2",
11
11
  });
12
12
  registerAulaVirtualTools(server, aula, files);
13
13
  return server;
@@ -41,7 +41,8 @@ button:hover{background:var(--accent)}
41
41
  .aviso{border:1px solid var(--err);padding:0 1rem;margin:1rem 0}
42
42
  label.acepto{flex-direction:row;align-items:flex-start;gap:.6rem;font:13px/1.5 ui-sans-serif,system-ui,sans-serif;
43
43
  text-transform:none;letter-spacing:normal;color:var(--ink)}
44
- input[type=checkbox]{padding:0;margin:.2rem 0 0;accent-color:var(--accent)}
44
+ input[type=checkbox],input[type=radio]{padding:0;margin:.2rem 0 0;accent-color:var(--accent)}
45
+ .numero{margin:1rem 0;font:600 2.5rem/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;text-align:center}
45
46
  ul{padding-left:1.2rem}
46
47
  ::selection{background:var(--accent);color:var(--paper)}
47
48
  `.replace(/\n/g, "");
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-mcp",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Este es el Model Context Protocol server NO OFICIAL de la UTN para buscar informacion o archivos en el aula virtual.",
5
5
  "main": "build/index.js",
6
6
  "bin": {