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 +1 -1
- package/build/aula-virtual/client/link.js +3 -2
- package/build/aula-virtual/client/local-session.js +3 -3
- package/build/aula-virtual/client/sso-login.js +144 -17
- package/build/aula-virtual/client/stored-session.js +4 -2
- package/build/aula-virtual/login.js +14 -2
- package/build/oauth/admin.js +7 -1
- package/build/oauth/pending-login.js +33 -0
- package/build/oauth/server.js +86 -41
- package/build/server.js +1 -1
- package/build/shared/pages.js +2 -1
- package/package.json +1 -1
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.
|
|
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
|
|
59
|
-
const ssoHref =
|
|
60
|
-
if (sso &&
|
|
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
|
|
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(
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
/**
|
|
111
|
-
|
|
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
|
|
115
|
-
* que vuelve al aula con el `code`. No completa
|
|
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
|
-
|
|
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
|
}
|
package/build/oauth/admin.js
CHANGED
|
@@ -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
|
+
}
|
package/build/oauth/server.js
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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.
|
|
10
|
+
version: "0.8.2",
|
|
11
11
|
});
|
|
12
12
|
registerAulaVirtualTools(server, aula, files);
|
|
13
13
|
return server;
|
package/build/shared/pages.js
CHANGED
|
@@ -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