utn-mcp 0.8.1 → 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
@@ -120,7 +120,7 @@ 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), 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).
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
 
@@ -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,
@@ -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,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";
@@ -38,8 +39,10 @@ const PROVIDERS = {
38
39
  * plugin de mobile launch, que puede estar deshabilitado); si falla, flujo de launch.php.
39
40
  * Las cookies se usan solo durante el login y se descartan: las sesiones de Moodle y del SSO duran
40
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`.
41
44
  */
42
- export async function fetchToken(regional, credentials) {
45
+ export async function fetchToken(regional, credentials, secondFactor) {
43
46
  if (regional.support === "unsupported")
44
47
  throw new UnsupportedRegionalError(regional);
45
48
  const { baseUrl, sso } = regional;
@@ -55,14 +58,14 @@ export async function fetchToken(regional, credentials) {
55
58
  let landing = await follow(jar, launchUrl);
56
59
  if ("token" in landing)
57
60
  return landing.token;
58
- const provider = sso && PROVIDERS[sso.provider];
59
- const ssoHref = provider && landing.html.match(provider.link)?.[1];
60
- if (sso && provider && ssoHref) {
61
+ const ssoProvider = sso ? PROVIDERS[sso.provider] : undefined;
62
+ const ssoHref = ssoProvider && landing.html.match(ssoProvider.link)?.[1];
63
+ if (sso && ssoProvider && ssoHref) {
61
64
  landing = await follow(jar, new URL(decodeHtml(ssoHref), landing.url).toString());
62
65
  if ("token" in landing)
63
66
  return landing.token;
64
67
  if (new URL(landing.url).host === sso.host) {
65
- landing = await provider.submit(jar, landing, credentials);
68
+ landing = await ssoProvider.submit(jar, landing, credentials, secondFactor);
66
69
  if ("token" in landing)
67
70
  return landing.token;
68
71
  if (new URL(landing.url).host === sso.host) {
@@ -70,7 +73,7 @@ export async function fetchToken(regional, credentials) {
70
73
  const viaTokenPhp = await tryTokenPhp(baseUrl, credentials);
71
74
  if (viaTokenPhp)
72
75
  return viaTokenPhp;
73
- throw new InvalidCredentialsError(provider.loginError(landing.html));
76
+ throw new InvalidCredentialsError(ssoProvider.loginError(landing.html));
74
77
  }
75
78
  }
76
79
  }
@@ -100,21 +103,44 @@ export async function fetchToken(regional, credentials) {
100
103
  }
101
104
  throw unexpected(landing.url);
102
105
  }
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}` : ""));
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`.");
107
111
  this.name = "MfaRequiredError";
108
112
  }
109
113
  }
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;
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;
112
138
  /**
113
139
  * 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).
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).
116
142
  */
117
- async function submitMicrosoftForm(jar, page, { username, password }) {
143
+ async function submitMicrosoftForm(jar, page, { username, password }, secondFactor) {
118
144
  const ssoHost = new URL(page.url).host;
119
145
  const cfg = parseAadConfig(page.html);
120
146
  if (!cfg?.urlPost || !cfg.sFT) {
@@ -144,8 +170,12 @@ async function submitMicrosoftForm(jar, page, { username, password }) {
144
170
  return follow(jar, action, { method: "POST", body: new URLSearchParams(form.inputs) });
145
171
  }
146
172
  const step = parseAadConfig(landing.html);
147
- if (step?.arrUserProofs?.length)
148
- throw new MfaRequiredError(step.strServiceExceptionMessage);
173
+ if (step?.arrUserProofs?.length) {
174
+ if (!secondFactor)
175
+ throw new MfaRequiredError();
176
+ landing = await completeMfa(jar, landing, step, username, secondFactor);
177
+ continue;
178
+ }
149
179
  if (step?.sErrorCode)
150
180
  return landing;
151
181
  if (step?.urlPost) {
@@ -169,6 +199,103 @@ async function submitMicrosoftForm(jar, page, { username, password }) {
169
199
  }
170
200
  throw new Error("Demasiados pasos en el login de Microsoft.");
171
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
+ }
172
299
  /** El motivo del rechazo en una página de Microsoft (ej. AADSTS50126: usuario o contraseña incorrectos). */
173
300
  function microsoftLoginError(html) {
174
301
  const cfg = parseAadConfig(html);
@@ -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));
@@ -22,7 +22,7 @@ export async function runLogin(http) {
22
22
  if (http)
23
23
  accessToken = await loginToStore(regional, credentials);
24
24
  else
25
- await new LocalSession().login(regional, credentials);
25
+ await new LocalSession().login(regional, credentials, askSecondFactor);
26
26
  // Se guardan recién después de comprobar que el aula las aceptó.
27
27
  if (await saveCredentials(credentials)) {
28
28
  logger.info("Credenciales guardadas en el keychain del sistema.");
@@ -50,9 +50,21 @@ async function askRegional() {
50
50
  const labels = regionals.map((r) => r.name + SUPPORT_LABEL[r.support]);
51
51
  return regionals[await askChoice("Regional:", labels, Math.max(initial, 0))];
52
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
+ };
53
65
  /** Crea el usuario si no existe, guarda su sesión del aula y le emite un token que no vence. */
54
66
  async function loginToStore(regional, credentials) {
55
67
  const store = await SqliteStore.open();
56
- const { userId } = await linkAulaAccount(store, regional, credentials);
68
+ const { userId } = await linkAulaAccount(store, regional, credentials, askSecondFactor);
57
69
  return store.issueToken(userId, { kind: "access", clientId: "local" });
58
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";
@@ -108,6 +108,12 @@ export function mountAdmin(app, store, secret) {
108
108
  if (e instanceof InvalidCredentialsError) {
109
109
  return void res.status(401).json({ error: "credenciales", motivo: "El aula rechazó el usuario o la contraseña." });
110
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
+ }
111
117
  logger.warn(`Verificación de ${username} (${regional.id}): ${e.message}`);
112
118
  res.status(502).json({ error: "aula", motivo: `No pudimos entrar al aula de ${regional.name}.` });
113
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.1",
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, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "utn-mcp",
3
- "version": "0.8.1",
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": {