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 +3 -3
- package/build/aula-virtual/client/link.js +6 -4
- package/build/aula-virtual/client/local-session.js +3 -3
- package/build/aula-virtual/client/regionals.js +17 -8
- package/build/aula-virtual/client/sso-login.js +293 -15
- package/build/aula-virtual/client/stored-session.js +4 -2
- package/build/aula-virtual/login.js +16 -3
- package/build/oauth/admin.js +15 -4
- 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/build/shared/store/sqlite-store.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Necesitás [Node.js](https://nodejs.org) 20 o superior. No hace falta clonar el
|
|
|
48
48
|
```bash
|
|
49
49
|
npx -y utn-mcp login
|
|
50
50
|
```
|
|
51
|
-
Te pide tu regional, tu usuario y tu contraseña: los mismos con los que entrás al aula virtual. Si no tenés usuario institucional (por ejemplo, en el curso de ingreso), usá el que te dieron para el aula. Se hace una sola vez: después el servidor renueva la sesión solo.
|
|
51
|
+
Te pide tu regional, tu usuario y tu contraseña: los mismos con los que entrás al aula virtual. Si no tenés usuario institucional (por ejemplo, en el curso de ingreso), usá el que te dieron para el aula. En Córdoba (FRC) los alumnos entran con el mail completo (`<legajo>@<especialidad>.frc.utn.edu.ar`), no con el legajo solo; con una cuenta `@frc.utn.edu.ar`, con lo que va antes de la `@`. En Haedo (FRH) y San Nicolás (FRSN) se entra con el mail completo de Office 365 (en FRH, del estilo `<inicial><apellido><3 dígitos del legajo>@alumnos.frh.utn.edu.ar`). Se hace una sola vez: después el servidor renueva la sesión solo.
|
|
52
52
|
|
|
53
53
|
### 2. Agregarlo a tu cliente
|
|
54
54
|
|
|
@@ -120,14 +120,14 @@ utn-mcp login
|
|
|
120
120
|
y en la config del cliente usás `"command": "utn-mcp"` sin argumentos.
|
|
121
121
|
|
|
122
122
|
### Regionales
|
|
123
|
-
La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. El login se adapta solo: si el aula entra por SSO (
|
|
123
|
+
La regional se elige al iniciar sesión, de una lista con las regionales de la UTN. Buenos Aires (FRBA), Córdoba (FRC), Resistencia (FRRE) y Tucumán (FRT) ya están probadas. También está el aula de la Tecnicatura en Programación a distancia del SIED (TUP). El login se adapta solo: si el aula entra por SSO, sea un Keycloak propio (FRBA) u Office 365 (Haedo, FRH, y San Nicolás, FRSN), usa el SSO; si no, el login directo de Moodle. 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
|
|
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
|
-
|
|
14
|
+
sso: { provider: "keycloak", host: "auth.frba.utn.edu.ar" },
|
|
13
15
|
support: "verified",
|
|
14
16
|
},
|
|
15
17
|
{ id: "frch", name: "Chubut (FRCh)", baseUrl: "https://frch.cvg.utn.edu.ar", support: "candidate" },
|
|
@@ -18,11 +20,11 @@ const REGIONALS = [
|
|
|
18
20
|
{ id: "frc", name: "Córdoba (FRC)", baseUrl: "https://uv.frc.utn.edu.ar", support: "verified" },
|
|
19
21
|
{ id: "frd", name: "Delta (FRD)", baseUrl: "https://frd.cvg.utn.edu.ar", support: "candidate" },
|
|
20
22
|
{ id: "frgp", name: "General Pacheco (FRGP)", baseUrl: "https://frgp.cvg.utn.edu.ar", support: "candidate" },
|
|
21
|
-
|
|
22
|
-
{ id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", support: "unsupported" },
|
|
23
|
+
{ id: "frh", name: "Haedo (FRH)", baseUrl: "https://frh.cvg.utn.edu.ar", sso: MICROSOFT_SSO, support: "candidate" },
|
|
23
24
|
{ id: "frlp", name: "La Plata (FRLP)", baseUrl: "https://frlp.cvg.utn.edu.ar", support: "candidate" },
|
|
24
25
|
{ id: "frlr", name: "La Rioja (FRLR)", baseUrl: "https://frlr.cvg.utn.edu.ar", support: "candidate" },
|
|
25
|
-
// Tiene también frmdp.cvg.utn.edu.ar, pero ese
|
|
26
|
+
// Tiene también frmdp.cvg.utn.edu.ar, pero ese entra con Office 365 y no tiene el mobile web service
|
|
27
|
+
// habilitado, así que no puede emitir token aunque se resuelva el login.
|
|
26
28
|
{ id: "frmdp", name: "Mar del Plata (FRMDP)", baseUrl: "https://campus.mdp.utn.edu.ar", support: "candidate" },
|
|
27
29
|
// Sin el servicio habilitado.
|
|
28
30
|
{ id: "frm", name: "Mendoza (FRM)", baseUrl: "https://campusvirtual.frm.utn.edu.ar", support: "unsupported" },
|
|
@@ -33,8 +35,7 @@ const REGIONALS = [
|
|
|
33
35
|
{ id: "frre", name: "Resistencia (FRRE)", baseUrl: "https://frre.cvg.utn.edu.ar", support: "verified" },
|
|
34
36
|
{ id: "frro", name: "Rosario (FRRo)", baseUrl: "https://frro.cvg.utn.edu.ar", support: "candidate" },
|
|
35
37
|
{ id: "frsfco", name: "San Francisco (FRSFco)", baseUrl: "https://frsfco.cvg.utn.edu.ar", support: "candidate" },
|
|
36
|
-
|
|
37
|
-
{ id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", support: "unsupported" },
|
|
38
|
+
{ id: "frsn", name: "San Nicolás (FRSN)", baseUrl: "https://frsn.cvg.utn.edu.ar", sso: MICROSOFT_SSO, support: "candidate" },
|
|
38
39
|
{ id: "frsr", name: "San Rafael (FRSR)", baseUrl: "https://campus.frsr.utn.edu.ar/moodle", support: "candidate" },
|
|
39
40
|
{ id: "frsc", name: "Santa Cruz (FRSC)", baseUrl: "https://frsc.cvg.utn.edu.ar", support: "candidate" },
|
|
40
41
|
{ id: "frsf", name: "Santa Fe (FRSF)", baseUrl: "https://campusvirtual.frsf.utn.edu.ar", support: "candidate" },
|
|
@@ -42,6 +43,8 @@ const REGIONALS = [
|
|
|
42
43
|
{ id: "frtdf", name: "Tierra del Fuego (FRTDF)", baseUrl: "https://cvg.frtdf.utn.edu.ar", support: "unsupported" },
|
|
43
44
|
{ id: "frtl", name: "Trenque Lauquen (FRTL)", baseUrl: "https://frtl.cvg.utn.edu.ar", support: "candidate" },
|
|
44
45
|
{ id: "frt", name: "Tucumán (FRT)", baseUrl: "https://frt.cvg.utn.edu.ar", support: "verified" },
|
|
46
|
+
// No es una regional sino el aula de la Tecnicatura en Programación a distancia del SIED.
|
|
47
|
+
{ id: "tup", name: "Tecnicatura en Programación (SIED)", baseUrl: "https://tup.sied.utn.edu.ar", support: "verified" },
|
|
45
48
|
// Sin el servicio habilitado.
|
|
46
49
|
{ id: "frvt", name: "Venado Tuerto (FRVT)", baseUrl: "https://frvt.cvg.utn.edu.ar", support: "unsupported" },
|
|
47
50
|
];
|
|
@@ -52,7 +55,7 @@ export class UnknownRegionalError extends Error {
|
|
|
52
55
|
constructor(regional) {
|
|
53
56
|
super(`"${regional}" no es una regional conocida. Las válidas son: ${availableRegionals().map((r) => r.id).join(", ")}. ` +
|
|
54
57
|
"Si tu campus no está en la lista, borrá UTN_REGIONAL y definí UTN_AULA_BASE_URL con la URL del aula " +
|
|
55
|
-
"(y UTN_SSO_HOST si
|
|
58
|
+
"(y UTN_SSO_HOST si entra por SSO: el host del Keycloak, o login.microsoftonline.com si es Office 365).");
|
|
56
59
|
}
|
|
57
60
|
}
|
|
58
61
|
/** Su aula no tiene el servicio habilitado o entra por un SSO que no sabemos usar: ni se intenta entrar. */
|
|
@@ -72,7 +75,13 @@ function customRegional() {
|
|
|
72
75
|
return undefined;
|
|
73
76
|
const baseUrl = raw.replace(/\/+$/, "");
|
|
74
77
|
const host = new URL(baseUrl).host;
|
|
75
|
-
|
|
78
|
+
const ssoHost = process.env.UTN_SSO_HOST?.trim();
|
|
79
|
+
const sso = !ssoHost
|
|
80
|
+
? undefined
|
|
81
|
+
: ssoHost === MICROSOFT_SSO.host
|
|
82
|
+
? MICROSOFT_SSO
|
|
83
|
+
: { provider: "keycloak", host: ssoHost };
|
|
84
|
+
return { id: host, name: host, baseUrl, sso, support: "verified" };
|
|
76
85
|
}
|
|
77
86
|
/**
|
|
78
87
|
* Las que se pueden elegir al iniciar sesión: el catálogo y, si está definida, la de UTN_AULA_BASE_URL.
|
|
@@ -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,
|
|
23
|
-
* SSO rechaza las credenciales, `login/token.php`: hay usuarios sin cuenta en
|
|
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,
|
|
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 (!
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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 (
|
|
51
|
-
landing = await
|
|
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 (
|
|
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(
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
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";
|
|
@@ -77,8 +77,8 @@ export function mountAdmin(app, store, secret) {
|
|
|
77
77
|
});
|
|
78
78
|
/**
|
|
79
79
|
* `{ regional, usuario, contrasena }` → entra al aula como el login de OAuth: crea el usuario si no
|
|
80
|
-
* existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email
|
|
81
|
-
* no entra, `{ error, motivo }`.
|
|
80
|
+
* existe y le guarda la sesión, así ya puede usar las tools. Devuelve `{ usuario, nombre, email,
|
|
81
|
+
* primero? }`, con `primero` si fue el primero en verificar la regional; si no entra, `{ error, motivo }`.
|
|
82
82
|
*/
|
|
83
83
|
app.post("/admin/verificar", requireSecret, json, async (req, res) => {
|
|
84
84
|
const id = typeof req.body?.regional === "string" ? req.body.regional : "";
|
|
@@ -97,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({
|
|
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
|
+
}
|
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, "");
|
|
@@ -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