@wilm-ai/wilma-client 1.5.2 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/dist/client.d.ts +67 -2
- package/dist/client.js +167 -128
- package/dist/external-fetch.d.ts +18 -0
- package/dist/external-fetch.js +263 -0
- package/dist/finnish-time.d.ts +19 -0
- package/dist/finnish-time.js +64 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/parsers/attendance.d.ts +11 -4
- package/dist/parsers/attendance.js +66 -19
- package/dist/parsers/dates.js +14 -17
- package/dist/parsers/exams.d.ts +8 -1
- package/dist/parsers/exams.js +37 -0
- package/dist/parsers/gradebook.d.ts +8 -0
- package/dist/parsers/gradebook.js +44 -0
- package/dist/parsers/html-text.d.ts +6 -0
- package/dist/parsers/html-text.js +42 -0
- package/dist/parsers/messages.d.ts +6 -2
- package/dist/parsers/messages.js +54 -8
- package/dist/parsers/news.js +61 -7
- package/dist/parsers/overview.d.ts +2 -1
- package/dist/parsers/overview.js +4 -3
- package/dist/parsers/printouts.d.ts +3 -0
- package/dist/parsers/printouts.js +20 -0
- package/dist/parsers/schedule.d.ts +6 -0
- package/dist/parsers/schedule.js +42 -0
- package/dist/parsers/students.d.ts +2 -5
- package/dist/parsers/students.js +22 -7
- package/dist/session.d.ts +58 -6
- package/dist/session.js +357 -77
- package/dist/timeouts.d.ts +20 -0
- package/dist/timeouts.js +82 -0
- package/dist/types.d.ts +56 -0
- package/package.json +21 -7
package/dist/session.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RequestInit, type Response } from "undici";
|
|
2
2
|
export declare class AuthenticationError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
3
4
|
}
|
|
4
5
|
export declare class MfaRequiredError extends Error {
|
|
5
6
|
formkey: string;
|
|
@@ -11,24 +12,75 @@ export declare class APIError extends Error {
|
|
|
11
12
|
}
|
|
12
13
|
export declare class WilmaSession {
|
|
13
14
|
private baseUrl;
|
|
14
|
-
private
|
|
15
|
-
private loggedIn;
|
|
16
|
-
private username?;
|
|
17
|
-
private password?;
|
|
15
|
+
private auth;
|
|
18
16
|
private studentNumber?;
|
|
19
17
|
private debug;
|
|
20
18
|
constructor(baseUrl: string, opts?: {
|
|
21
19
|
studentNumber?: string | null;
|
|
22
20
|
debug?: boolean;
|
|
23
21
|
});
|
|
22
|
+
/** The login's cookies, so another process can resume the session (see resumeState). */
|
|
23
|
+
exportState(): string;
|
|
24
|
+
/**
|
|
25
|
+
* Continue a session saved by exportState() instead of logging in. If Wilma
|
|
26
|
+
* has ended it meanwhile, the first request notices (401/403 or the login
|
|
27
|
+
* page) and logs in again with these credentials. Returns false when the
|
|
28
|
+
* state holds no session to resume.
|
|
29
|
+
*/
|
|
30
|
+
resumeState(state: string, username: string, password: string): boolean;
|
|
31
|
+
/** Called after every successful login (first or again). */
|
|
32
|
+
onLogin(callback: (() => void) | undefined): void;
|
|
33
|
+
/** Used to answer the two-step verification challenge when the session has to log in again. */
|
|
34
|
+
setMfaCallback(onMfa?: (formkey: string) => Promise<string>): void;
|
|
35
|
+
/**
|
|
36
|
+
* A session for another student on the same account. It shares this
|
|
37
|
+
* session's login (cookies and credentials), so no new login is needed —
|
|
38
|
+
* Wilma picks the student from the "/!<number>/" path prefix.
|
|
39
|
+
*/
|
|
40
|
+
forStudent(studentNumber: string | null): WilmaSession;
|
|
24
41
|
get urlPrefix(): string | null;
|
|
25
42
|
getPrefixedPath(path: string): string;
|
|
26
43
|
login(username: string, password: string): Promise<void>;
|
|
27
44
|
submitMfaCode(formkey: string, otpCode: string): Promise<void>;
|
|
28
45
|
private getLoginFormFields;
|
|
29
|
-
|
|
30
|
-
|
|
46
|
+
/**
|
|
47
|
+
* An authenticated request to this Wilma. Follows redirects within Wilma
|
|
48
|
+
* only; a redirect to another site is an error unless `externalRedirect`
|
|
49
|
+
* is "return" (then the 3xx response is handed back, e.g. so a download can
|
|
50
|
+
* continue without Wilma credentials).
|
|
51
|
+
*/
|
|
52
|
+
request(path: string, init?: RequestInit, opts?: {
|
|
53
|
+
externalRedirect?: "error" | "return"; /** Idle timeout, e.g. longer for downloads. */
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}): Promise<Response>;
|
|
56
|
+
/** A redirect to this Wilma's login page: the session is gone. */
|
|
57
|
+
private isLoginRedirect;
|
|
58
|
+
/**
|
|
59
|
+
* Is this login still live? A cancelled session gets 401/403 everywhere;
|
|
60
|
+
* a live one answers the account roles API (or 404 on Wilmas without it).
|
|
61
|
+
* Lets a 403 on one item be told apart from a cancelled session without
|
|
62
|
+
* logging in again (which would log the parent out of their own browser).
|
|
63
|
+
*/
|
|
64
|
+
private sessionAlive;
|
|
65
|
+
/**
|
|
66
|
+
* Answer a two-step verification challenge. If Wilma rejects the code (most
|
|
67
|
+
* likely because it was already used — logins less than 30 seconds apart
|
|
68
|
+
* share one), ask once more for the same challenge; the callback then
|
|
69
|
+
* waits for a fresh code.
|
|
70
|
+
*/
|
|
71
|
+
answerMfa(formkey: string, onMfa: (formkey: string) => Promise<string>): Promise<void>;
|
|
72
|
+
/** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
|
|
73
|
+
private relogin;
|
|
74
|
+
get(path: string, init?: RequestInit, opts?: {
|
|
75
|
+
externalRedirect?: "error" | "return";
|
|
76
|
+
timeoutMs?: number;
|
|
77
|
+
}): Promise<Response>;
|
|
31
78
|
post(path: string, init?: RequestInit): Promise<Response>;
|
|
32
79
|
private getLoginToken;
|
|
80
|
+
/**
|
|
81
|
+
* One request to this Wilma, following redirects within Wilma hop by hop
|
|
82
|
+
* (cookies are stored per hop, and only for Wilma). A redirect to another
|
|
83
|
+
* origin is returned as-is: Wilma's cookies never travel there.
|
|
84
|
+
*/
|
|
33
85
|
private rawRequest;
|
|
34
86
|
}
|
package/dist/session.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { CookieJar } from "tough-cookie";
|
|
2
2
|
import { fetch } from "undici";
|
|
3
|
-
import {
|
|
3
|
+
import { IdleTimer, asNetworkError, watchBody } from "./timeouts.js";
|
|
4
4
|
export class AuthenticationError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "AuthenticationError";
|
|
8
|
+
}
|
|
5
9
|
}
|
|
6
10
|
export class MfaRequiredError extends Error {
|
|
7
11
|
formkey;
|
|
8
12
|
constructor(formkey) {
|
|
9
13
|
super("MFA verification required");
|
|
14
|
+
this.name = "MfaRequiredError";
|
|
10
15
|
this.formkey = formkey;
|
|
11
16
|
}
|
|
12
17
|
}
|
|
@@ -14,26 +19,72 @@ export class APIError extends Error {
|
|
|
14
19
|
status;
|
|
15
20
|
constructor(message, status) {
|
|
16
21
|
super(message);
|
|
22
|
+
this.name = "APIError";
|
|
17
23
|
this.status = status;
|
|
18
24
|
}
|
|
19
25
|
}
|
|
26
|
+
/** How long Wilma may go silent (before answering, or mid-body) before a request fails. */
|
|
27
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
28
|
+
const MAX_REDIRECTS = 10;
|
|
20
29
|
const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
|
|
21
30
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36";
|
|
22
31
|
const LOGIN_FAIL_RE = /loginFailed/i;
|
|
23
32
|
export class WilmaSession {
|
|
24
33
|
baseUrl;
|
|
25
|
-
|
|
26
|
-
loggedIn = false;
|
|
27
|
-
username;
|
|
28
|
-
password;
|
|
34
|
+
auth;
|
|
29
35
|
studentNumber;
|
|
30
36
|
debug = false;
|
|
31
37
|
constructor(baseUrl, opts) {
|
|
32
38
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
33
|
-
this.
|
|
39
|
+
this.auth = { cookieJar: new CookieJar(), loggedIn: false, relogin: null, generation: 0, failed: null };
|
|
34
40
|
this.studentNumber = opts?.studentNumber ?? null;
|
|
35
41
|
this.debug = Boolean(opts?.debug);
|
|
36
42
|
}
|
|
43
|
+
/** The login's cookies, so another process can resume the session (see resumeState). */
|
|
44
|
+
exportState() {
|
|
45
|
+
return JSON.stringify(this.auth.cookieJar.serializeSync());
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Continue a session saved by exportState() instead of logging in. If Wilma
|
|
49
|
+
* has ended it meanwhile, the first request notices (401/403 or the login
|
|
50
|
+
* page) and logs in again with these credentials. Returns false when the
|
|
51
|
+
* state holds no session to resume.
|
|
52
|
+
*/
|
|
53
|
+
resumeState(state, username, password) {
|
|
54
|
+
let jar;
|
|
55
|
+
try {
|
|
56
|
+
jar = CookieJar.deserializeSync(JSON.parse(state));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (!jar.getCookiesSync(this.baseUrl).some((cookie) => cookie.key === "Wilma2SID"))
|
|
62
|
+
return false;
|
|
63
|
+
this.auth.cookieJar = jar;
|
|
64
|
+
this.auth.loggedIn = true;
|
|
65
|
+
this.auth.generation += 1;
|
|
66
|
+
this.auth.username = username;
|
|
67
|
+
this.auth.password = password;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
/** Called after every successful login (first or again). */
|
|
71
|
+
onLogin(callback) {
|
|
72
|
+
this.auth.onLogin = callback;
|
|
73
|
+
}
|
|
74
|
+
/** Used to answer the two-step verification challenge when the session has to log in again. */
|
|
75
|
+
setMfaCallback(onMfa) {
|
|
76
|
+
this.auth.onMfa = onMfa;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A session for another student on the same account. It shares this
|
|
80
|
+
* session's login (cookies and credentials), so no new login is needed —
|
|
81
|
+
* Wilma picks the student from the "/!<number>/" path prefix.
|
|
82
|
+
*/
|
|
83
|
+
forStudent(studentNumber) {
|
|
84
|
+
const session = new WilmaSession(this.baseUrl, { studentNumber, debug: this.debug });
|
|
85
|
+
session.auth = this.auth;
|
|
86
|
+
return session;
|
|
87
|
+
}
|
|
37
88
|
get urlPrefix() {
|
|
38
89
|
return this.studentNumber ? `!${this.studentNumber}` : null;
|
|
39
90
|
}
|
|
@@ -47,7 +98,7 @@ export class WilmaSession {
|
|
|
47
98
|
return path;
|
|
48
99
|
}
|
|
49
100
|
async login(username, password) {
|
|
50
|
-
if (this.loggedIn) {
|
|
101
|
+
if (this.auth.loggedIn) {
|
|
51
102
|
return;
|
|
52
103
|
}
|
|
53
104
|
const loginFields = await this.getLoginFormFields();
|
|
@@ -69,35 +120,53 @@ export class WilmaSession {
|
|
|
69
120
|
},
|
|
70
121
|
redirect: "manual",
|
|
71
122
|
});
|
|
72
|
-
const hasSessionCookie = this.cookieJar
|
|
123
|
+
const hasSessionCookie = this.auth.cookieJar
|
|
73
124
|
.getCookiesSync(this.baseUrl)
|
|
74
125
|
.some((c) => c.key === "Wilma2SID");
|
|
75
126
|
const text = await resp.text();
|
|
127
|
+
const location = resp.headers.get("location") ?? "";
|
|
128
|
+
// Wilma itself failing is temporary — not a verdict on the username and password.
|
|
129
|
+
if (resp.status >= 500) {
|
|
130
|
+
throw new APIError(`Wilma answered HTTP ${resp.status} while logging in`, resp.status);
|
|
131
|
+
}
|
|
132
|
+
// Wilma answers a wrong username or password with a redirect to
|
|
133
|
+
// "?loginfailed" — empty body, no session cookie (older versions: a page
|
|
134
|
+
// saying so).
|
|
135
|
+
if (LOGIN_FAIL_RE.test(location) || (resp.status < 300 && !isLoginOk(text))) {
|
|
136
|
+
throw new AuthenticationError("Wilma login failed");
|
|
137
|
+
}
|
|
76
138
|
// Check for MFA challenge by following the post-login redirect
|
|
77
|
-
if (resp.status >= 300 && resp.status < 400 && hasSessionCookie) {
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
const redirectResp = await this.rawRequest(
|
|
139
|
+
if (resp.status >= 300 && resp.status < 400 && hasSessionCookie && location) {
|
|
140
|
+
const next = new URL(location, `${this.baseUrl}/`);
|
|
141
|
+
if (next.origin === new URL(this.baseUrl).origin) {
|
|
142
|
+
const redirectResp = await this.rawRequest(next.pathname + next.search, { method: "GET" });
|
|
81
143
|
const redirectText = await redirectResp.text();
|
|
82
144
|
const mfaFormkeyMatch = /id="mfa-formkey"\s+value="([^"]+)"/.exec(redirectText);
|
|
83
145
|
if (mfaFormkeyMatch) {
|
|
84
146
|
if (this.debug) {
|
|
85
|
-
console.
|
|
147
|
+
console.error(`[wilmai] MFA challenge detected`);
|
|
86
148
|
}
|
|
87
149
|
// Store credentials so we can complete login after MFA
|
|
88
|
-
this.username = username;
|
|
89
|
-
this.password = password;
|
|
150
|
+
this.auth.username = username;
|
|
151
|
+
this.auth.password = password;
|
|
90
152
|
throw new MfaRequiredError(mfaFormkeyMatch[1]);
|
|
91
153
|
}
|
|
92
154
|
}
|
|
93
155
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.
|
|
156
|
+
// A redirect counts as a login only with a session cookie: its empty body
|
|
157
|
+
// would otherwise pass the text check, accepting any username and password.
|
|
158
|
+
if (resp.status < 400 && (hasSessionCookie || resp.status < 300)) {
|
|
159
|
+
this.auth.loggedIn = true;
|
|
160
|
+
this.auth.generation += 1;
|
|
161
|
+
this.auth.failed = null;
|
|
162
|
+
this.auth.username = username;
|
|
163
|
+
this.auth.password = password;
|
|
164
|
+
this.auth.onLogin?.();
|
|
98
165
|
return;
|
|
99
166
|
}
|
|
100
|
-
|
|
167
|
+
// Not Wilma's "login failed" answer (a rate limit, a firewall, a
|
|
168
|
+
// maintenance page): report it, but don't treat the password as wrong.
|
|
169
|
+
throw new APIError(`Wilma didn't accept the login right now (HTTP ${resp.status})`, resp.status);
|
|
101
170
|
}
|
|
102
171
|
async submitMfaCode(formkey, otpCode) {
|
|
103
172
|
// Wilma's MFA endpoint: POST /api/v1/accounts/me/mfa/otp/check
|
|
@@ -109,7 +178,7 @@ export class WilmaSession {
|
|
|
109
178
|
payload: JSON.stringify({ otp: otpCode, action: "login" }),
|
|
110
179
|
});
|
|
111
180
|
if (this.debug) {
|
|
112
|
-
console.
|
|
181
|
+
console.error(`[wilmai] POST ${path} (MFA OTP check)`);
|
|
113
182
|
}
|
|
114
183
|
const resp = await this.rawRequest(path, {
|
|
115
184
|
method: "POST",
|
|
@@ -118,6 +187,11 @@ export class WilmaSession {
|
|
|
118
187
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
|
119
188
|
},
|
|
120
189
|
});
|
|
190
|
+
// Wilma itself failing says nothing about the code.
|
|
191
|
+
if (resp.status >= 500) {
|
|
192
|
+
await resp.body?.cancel();
|
|
193
|
+
throw new APIError(`Wilma answered HTTP ${resp.status} while checking the code`, resp.status);
|
|
194
|
+
}
|
|
121
195
|
const text = await resp.text();
|
|
122
196
|
let success = false;
|
|
123
197
|
try {
|
|
@@ -131,40 +205,192 @@ export class WilmaSession {
|
|
|
131
205
|
if (!success) {
|
|
132
206
|
throw new AuthenticationError("MFA verification failed: invalid OTP code");
|
|
133
207
|
}
|
|
134
|
-
this.loggedIn = true;
|
|
208
|
+
this.auth.loggedIn = true;
|
|
209
|
+
this.auth.generation += 1;
|
|
210
|
+
this.auth.failed = null;
|
|
211
|
+
this.auth.onLogin?.();
|
|
135
212
|
if (this.debug) {
|
|
136
|
-
console.
|
|
213
|
+
console.error(`[wilmai] MFA verification successful`);
|
|
137
214
|
}
|
|
138
215
|
}
|
|
139
216
|
async getLoginFormFields() {
|
|
140
217
|
const resp = await this.rawRequest("/login", { method: "GET" });
|
|
218
|
+
if (resp.status >= 500) {
|
|
219
|
+
await resp.body?.cancel();
|
|
220
|
+
throw new APIError(`Wilma answered HTTP ${resp.status} for its login page`, resp.status);
|
|
221
|
+
}
|
|
222
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
223
|
+
// rawRequest follows redirects within Wilma, so this one leads elsewhere.
|
|
224
|
+
await resp.body?.cancel();
|
|
225
|
+
const target = safeOrigin(resp.headers.get("location"), this.baseUrl);
|
|
226
|
+
throw new APIError(`${new URL(this.baseUrl).origin} sends its login page to ${target ?? "another site"}; ` +
|
|
227
|
+
"log in with that Wilma address instead", resp.status);
|
|
228
|
+
}
|
|
141
229
|
if (resp.status >= 400) {
|
|
230
|
+
await resp.body?.cancel();
|
|
142
231
|
return {};
|
|
143
232
|
}
|
|
144
233
|
const html = await resp.text();
|
|
145
234
|
return parseLoginFormFields(html);
|
|
146
235
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
236
|
+
/**
|
|
237
|
+
* An authenticated request to this Wilma. Follows redirects within Wilma
|
|
238
|
+
* only; a redirect to another site is an error unless `externalRedirect`
|
|
239
|
+
* is "return" (then the 3xx response is handed back, e.g. so a download can
|
|
240
|
+
* continue without Wilma credentials).
|
|
241
|
+
*/
|
|
242
|
+
async request(path, init, opts = {}) {
|
|
243
|
+
const auth = this.auth;
|
|
244
|
+
if (auth.failed)
|
|
245
|
+
throw auth.failed;
|
|
246
|
+
// A re-login in progress: wait for it (and share its outcome).
|
|
247
|
+
if (auth.relogin)
|
|
248
|
+
await auth.relogin;
|
|
249
|
+
if (!auth.loggedIn) {
|
|
250
|
+
// An earlier re-login failed (Wilma down, network blip): try again now.
|
|
251
|
+
if (auth.username && auth.password)
|
|
252
|
+
await this.relogin();
|
|
253
|
+
else
|
|
254
|
+
throw new AuthenticationError("WilmaSession not logged in – call login() first");
|
|
150
255
|
}
|
|
151
256
|
const prefixedPath = this.getPrefixedPath(path);
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
257
|
+
const generation = auth.generation;
|
|
258
|
+
// Some pages (messages) answer a cancelled session with a redirect to the
|
|
259
|
+
// login page instead of 401/403: stop there rather than read the login
|
|
260
|
+
// page as an empty answer.
|
|
261
|
+
const authed = { ...init, stopAtLogin: true };
|
|
262
|
+
let resp = await this.rawRequest(prefixedPath, authed, opts.timeoutMs);
|
|
263
|
+
// 401 or a redirect to the login page: the session expired. 403: either
|
|
264
|
+
// another login on the same account cancelled this session, or just this
|
|
265
|
+
// one item is off-limits.
|
|
266
|
+
const loggedOut = (r) => r.status === 401 || this.isLoginRedirect(r);
|
|
267
|
+
if ((loggedOut(resp) || resp.status === 403) && auth.username && auth.password) {
|
|
268
|
+
const someoneElseLoggedIn = () => auth.generation !== generation || auth.relogin !== null;
|
|
269
|
+
let retry = someoneElseLoggedIn();
|
|
270
|
+
if (!retry && (loggedOut(resp) || !(await this.sessionAlive()))) {
|
|
271
|
+
// Check again: another request may have logged in while we probed.
|
|
272
|
+
if (!someoneElseLoggedIn())
|
|
273
|
+
await this.relogin();
|
|
274
|
+
retry = true;
|
|
275
|
+
}
|
|
276
|
+
if (retry) {
|
|
277
|
+
if (auth.relogin)
|
|
278
|
+
await auth.relogin;
|
|
279
|
+
await resp.body?.cancel();
|
|
280
|
+
resp = await this.rawRequest(prefixedPath, authed, opts.timeoutMs);
|
|
281
|
+
}
|
|
157
282
|
}
|
|
158
283
|
if (this.debug) {
|
|
159
|
-
console.
|
|
284
|
+
console.error(`[wilmai] ${init?.method ?? "GET"} ${redactPath(prefixedPath)} => ${resp.status}`);
|
|
285
|
+
}
|
|
286
|
+
if (this.isLoginRedirect(resp)) {
|
|
287
|
+
await resp.body?.cancel();
|
|
288
|
+
throw new APIError(`Wilma asked to log in again at ${redactPath(prefixedPath)}`, 401);
|
|
289
|
+
}
|
|
290
|
+
if (resp.status >= 300 && resp.status < 400) {
|
|
291
|
+
// rawRequest follows redirects within Wilma, so this one leads elsewhere.
|
|
292
|
+
if (opts.externalRedirect === "return")
|
|
293
|
+
return resp;
|
|
294
|
+
await resp.body?.cancel();
|
|
295
|
+
throw new APIError(`Wilma redirected to another site at ${redactPath(prefixedPath)}`, resp.status);
|
|
160
296
|
}
|
|
161
297
|
if (resp.status >= 400) {
|
|
162
|
-
|
|
298
|
+
await resp.body?.cancel();
|
|
299
|
+
throw new APIError(`Wilma HTTP ${resp.status} at ${redactPath(prefixedPath)}`, resp.status);
|
|
163
300
|
}
|
|
164
301
|
return resp;
|
|
165
302
|
}
|
|
166
|
-
|
|
167
|
-
|
|
303
|
+
/** A redirect to this Wilma's login page: the session is gone. */
|
|
304
|
+
isLoginRedirect(resp) {
|
|
305
|
+
if (resp.status < 300 || resp.status >= 400)
|
|
306
|
+
return false;
|
|
307
|
+
const location = resp.headers.get("location");
|
|
308
|
+
if (!location)
|
|
309
|
+
return false;
|
|
310
|
+
try {
|
|
311
|
+
const target = new URL(location, `${this.baseUrl}/`);
|
|
312
|
+
return target.origin === new URL(this.baseUrl).origin && /^\/login\/?$/i.test(target.pathname);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Is this login still live? A cancelled session gets 401/403 everywhere;
|
|
320
|
+
* a live one answers the account roles API (or 404 on Wilmas without it).
|
|
321
|
+
* Lets a 403 on one item be told apart from a cancelled session without
|
|
322
|
+
* logging in again (which would log the parent out of their own browser).
|
|
323
|
+
*/
|
|
324
|
+
async sessionAlive() {
|
|
325
|
+
try {
|
|
326
|
+
const probe = await this.rawRequest("/api/v1/accounts/me/roles", { method: "GET", redirect: "manual" });
|
|
327
|
+
await probe.body?.cancel();
|
|
328
|
+
return probe.status !== 401 && probe.status !== 403 && !(probe.status >= 300 && probe.status < 400);
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Answer a two-step verification challenge. If Wilma rejects the code (most
|
|
336
|
+
* likely because it was already used — logins less than 30 seconds apart
|
|
337
|
+
* share one), ask once more for the same challenge; the callback then
|
|
338
|
+
* waits for a fresh code.
|
|
339
|
+
*/
|
|
340
|
+
async answerMfa(formkey, onMfa) {
|
|
341
|
+
try {
|
|
342
|
+
await this.submitMfaCode(formkey, await onMfa(formkey));
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
if (err instanceof AuthenticationError && /invalid OTP/i.test(err.message)) {
|
|
346
|
+
await this.submitMfaCode(formkey, await onMfa(formkey));
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
throw err;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Log in again, once for everyone sharing this login even if many requests notice at the same time. */
|
|
354
|
+
async relogin() {
|
|
355
|
+
if (!this.auth.relogin) {
|
|
356
|
+
const auth = this.auth;
|
|
357
|
+
const attempt = (async () => {
|
|
358
|
+
auth.loggedIn = false;
|
|
359
|
+
// Start from a clean jar: a leftover session cookie must not make a
|
|
360
|
+
// failed login look successful.
|
|
361
|
+
auth.cookieJar.removeAllCookiesSync();
|
|
362
|
+
if (this.debug)
|
|
363
|
+
console.error("[wilmai] session cancelled or expired; logging in again");
|
|
364
|
+
try {
|
|
365
|
+
await this.login(auth.username, auth.password);
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
if (err instanceof MfaRequiredError && auth.onMfa) {
|
|
369
|
+
await this.answerMfa(err.formkey, auth.onMfa);
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
})();
|
|
376
|
+
// No overall deadline: every request in the login has its own idle
|
|
377
|
+
// timeout, and an interactive code prompt may rightly take a while. (A
|
|
378
|
+
// deadline would also let a second re-login start under this one.)
|
|
379
|
+
auth.relogin = attempt
|
|
380
|
+
.catch((err) => {
|
|
381
|
+
// The saved login itself was refused: stop here instead of retrying on every request.
|
|
382
|
+
if (isPermanentLoginFailure(err))
|
|
383
|
+
auth.failed = err;
|
|
384
|
+
throw err;
|
|
385
|
+
})
|
|
386
|
+
.finally(() => {
|
|
387
|
+
auth.relogin = null;
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
await this.auth.relogin;
|
|
391
|
+
}
|
|
392
|
+
async get(path, init, opts) {
|
|
393
|
+
return this.request(path, { ...init, method: "GET" }, opts);
|
|
168
394
|
}
|
|
169
395
|
async post(path, init) {
|
|
170
396
|
return this.request(path, { ...init, method: "POST" });
|
|
@@ -172,7 +398,9 @@ export class WilmaSession {
|
|
|
172
398
|
async getLoginToken() {
|
|
173
399
|
const resp = await this.rawRequest("/token", { method: "GET" });
|
|
174
400
|
if (resp.status !== 200) {
|
|
175
|
-
|
|
401
|
+
// An outage or a changed login page, not a wrong password.
|
|
402
|
+
await resp.body?.cancel();
|
|
403
|
+
throw new APIError(`Wilma answered HTTP ${resp.status} for its login token`, resp.status);
|
|
176
404
|
}
|
|
177
405
|
const text = await resp.text();
|
|
178
406
|
try {
|
|
@@ -186,72 +414,124 @@ export class WilmaSession {
|
|
|
186
414
|
}
|
|
187
415
|
const match = /"Wilma2LoginID"\s*:\s*"([^"\s]+)"/.exec(text);
|
|
188
416
|
if (!match) {
|
|
189
|
-
throw new
|
|
417
|
+
throw new APIError("Wilma's login token wasn't in the expected format", resp.status);
|
|
190
418
|
}
|
|
191
419
|
return match[1];
|
|
192
420
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
421
|
+
/**
|
|
422
|
+
* One request to this Wilma, following redirects within Wilma hop by hop
|
|
423
|
+
* (cookies are stored per hop, and only for Wilma). A redirect to another
|
|
424
|
+
* origin is returned as-is: Wilma's cookies never travel there.
|
|
425
|
+
*/
|
|
426
|
+
async rawRequest(path, init, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
427
|
+
const origin = new URL(this.baseUrl).origin;
|
|
428
|
+
// Paths only: "//host/x" or "https://other/x" would leave Wilma.
|
|
429
|
+
if (path.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(path)) {
|
|
430
|
+
throw new APIError("Refusing a request outside Wilma", 400);
|
|
431
|
+
}
|
|
432
|
+
let url = new URL(path, `${this.baseUrl}/`);
|
|
433
|
+
if (url.origin !== origin) {
|
|
434
|
+
throw new APIError("Refusing a request outside Wilma", 400);
|
|
435
|
+
}
|
|
436
|
+
const baseHeaders = new Headers();
|
|
197
437
|
if (init?.headers) {
|
|
198
438
|
const incoming = init.headers;
|
|
199
439
|
if (incoming instanceof Headers) {
|
|
200
|
-
incoming.forEach((value, key) =>
|
|
440
|
+
incoming.forEach((value, key) => baseHeaders.set(key, value));
|
|
201
441
|
}
|
|
202
442
|
else if (Array.isArray(incoming)) {
|
|
203
443
|
for (const entry of incoming) {
|
|
204
444
|
if (entry.length >= 2) {
|
|
205
|
-
|
|
445
|
+
baseHeaders.set(entry[0], entry[1]);
|
|
206
446
|
}
|
|
207
447
|
}
|
|
208
448
|
}
|
|
209
449
|
else {
|
|
210
450
|
for (const [key, value] of Object.entries(incoming)) {
|
|
211
451
|
if (value !== undefined) {
|
|
212
|
-
|
|
452
|
+
baseHeaders.set(key, String(value));
|
|
213
453
|
}
|
|
214
454
|
}
|
|
215
455
|
}
|
|
216
456
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
headers,
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
catch (err) {
|
|
235
|
-
// `fetch failed` on its own is undiagnosable; re-throw with the cause code
|
|
236
|
-
// and remediation attached.
|
|
237
|
-
throw wrapNetworkError(err, url);
|
|
238
|
-
}
|
|
239
|
-
const headersAny = resp.headers;
|
|
240
|
-
const setCookies = headersAny.getSetCookie?.() ?? [];
|
|
241
|
-
if (setCookies.length) {
|
|
242
|
-
for (const cookie of setCookies) {
|
|
243
|
-
this.cookieJar.setCookieSync(cookie, url);
|
|
457
|
+
baseHeaders.set("User-Agent", USER_AGENT);
|
|
458
|
+
baseHeaders.set("Referer", `${this.baseUrl}/`);
|
|
459
|
+
const { stopAtLogin, ...fetchInit } = init ?? {};
|
|
460
|
+
const follow = init?.redirect !== "manual";
|
|
461
|
+
const idle = new IdleTimer(timeoutMs);
|
|
462
|
+
const signal = init?.signal ? AbortSignal.any([init.signal, idle.signal]) : idle.signal;
|
|
463
|
+
let method = (init?.method ?? "GET").toUpperCase();
|
|
464
|
+
let body = init?.body;
|
|
465
|
+
for (let hop = 0;; hop += 1) {
|
|
466
|
+
const headers = new Headers(baseHeaders);
|
|
467
|
+
const cookieHeader = this.auth.cookieJar.getCookieStringSync(url.href);
|
|
468
|
+
if (cookieHeader)
|
|
469
|
+
headers.set("Cookie", cookieHeader);
|
|
470
|
+
if (this.debug) {
|
|
471
|
+
// Method and path only: never headers, bodies or query strings.
|
|
472
|
+
console.error(`[wilmai] ${method} ${redactPath(url.pathname)}`);
|
|
244
473
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
474
|
+
let resp;
|
|
475
|
+
try {
|
|
476
|
+
idle.touch();
|
|
477
|
+
resp = await fetch(url, { ...fetchInit, method, body, headers, signal, redirect: "manual" });
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
idle.stop();
|
|
481
|
+
// Timeouts, and `fetch failed` with its cause code and remediation attached.
|
|
482
|
+
throw asNetworkError(err, origin);
|
|
483
|
+
}
|
|
484
|
+
const headersAny = resp.headers;
|
|
485
|
+
const setCookies = headersAny.getSetCookie?.() ?? [];
|
|
486
|
+
const single = setCookies.length ? [] : [resp.headers.get("set-cookie")].filter((c) => !!c);
|
|
487
|
+
for (const cookie of [...setCookies, ...single]) {
|
|
488
|
+
// A cookie Wilma isn't allowed to set (e.g. for another domain) is dropped, not fatal.
|
|
489
|
+
this.auth.cookieJar.setCookieSync(cookie, url.href, { ignoreError: true });
|
|
490
|
+
}
|
|
491
|
+
const location = resp.headers.get("location");
|
|
492
|
+
if (!follow || resp.status < 300 || resp.status >= 400 || !location) {
|
|
493
|
+
return watchBody(resp, idle, origin);
|
|
494
|
+
}
|
|
495
|
+
const next = new URL(location, url);
|
|
496
|
+
if (next.origin !== origin) {
|
|
497
|
+
return watchBody(resp, idle, origin); // hand the off-site redirect back to the caller
|
|
498
|
+
}
|
|
499
|
+
if (stopAtLogin && /^\/login\/?$/i.test(next.pathname)) {
|
|
500
|
+
return watchBody(resp, idle, origin); // logged out: the caller decides
|
|
250
501
|
}
|
|
502
|
+
if (hop >= MAX_REDIRECTS) {
|
|
503
|
+
idle.stop();
|
|
504
|
+
await resp.body?.cancel();
|
|
505
|
+
throw new APIError("Wilma redirected too many times", resp.status);
|
|
506
|
+
}
|
|
507
|
+
await resp.body?.cancel();
|
|
508
|
+
// Like browsers: 303, and 301/302 after a POST, continue as GET without a body.
|
|
509
|
+
if (resp.status === 303 || ((resp.status === 301 || resp.status === 302) && method === "POST")) {
|
|
510
|
+
method = "GET";
|
|
511
|
+
body = undefined;
|
|
512
|
+
baseHeaders.delete("Content-Type");
|
|
513
|
+
}
|
|
514
|
+
url = next;
|
|
251
515
|
}
|
|
252
|
-
return resp;
|
|
253
516
|
}
|
|
254
517
|
}
|
|
518
|
+
/** The origin of a redirect target, for messages (never its path). */
|
|
519
|
+
function safeOrigin(location, base) {
|
|
520
|
+
try {
|
|
521
|
+
return location ? new URL(location, `${base}/`).origin : null;
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/** A path for messages and logs: no student number, no query string. */
|
|
528
|
+
function redactPath(path) {
|
|
529
|
+
return path.replace(/^\/![^/]+/, "").split("?")[0] || "/";
|
|
530
|
+
}
|
|
531
|
+
function isPermanentLoginFailure(err) {
|
|
532
|
+
return (err instanceof MfaRequiredError ||
|
|
533
|
+
(err instanceof AuthenticationError && /^Wilma login failed/.test(err.message)));
|
|
534
|
+
}
|
|
255
535
|
function isLoginOk(text) {
|
|
256
536
|
return !LOGIN_FAIL_RE.test(text);
|
|
257
537
|
}
|