@wilm-ai/wilma-cli 2.3.2 → 2.5.0
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 +20 -3
- package/dist/access.d.ts +40 -1
- package/dist/access.js +105 -6
- package/dist/agent-data.d.ts +200 -21
- package/dist/agent-data.js +261 -56
- package/dist/cli-args.js +12 -3
- package/dist/cli-help.js +29 -13
- package/dist/cli-output.d.ts +4 -1
- package/dist/cli-output.js +19 -5
- package/dist/config.d.ts +36 -6
- package/dist/config.js +26 -0
- package/dist/credentials.d.ts +49 -2
- package/dist/credentials.js +199 -64
- package/dist/index.js +214 -108
- package/dist/login-server.d.ts +28 -6
- package/dist/login-server.js +468 -239
- package/dist/mcp-tools.d.ts +2 -2
- package/dist/mcp-tools.js +46 -15
- package/dist/mcp.js +26 -15
- package/dist/secret-moves.d.ts +44 -0
- package/dist/secret-moves.js +297 -0
- package/dist/secret-store.d.ts +45 -0
- package/dist/secret-store.js +487 -0
- package/dist/session-keys.d.ts +19 -0
- package/dist/session-keys.js +26 -0
- package/dist/session-store.d.ts +16 -0
- package/dist/session-store.js +69 -0
- package/package.json +8 -3
package/dist/cli-output.js
CHANGED
|
@@ -62,7 +62,9 @@ export function newsLine(item) {
|
|
|
62
62
|
export function messageLine(msg) {
|
|
63
63
|
const sender = msg.senderName ? ` — ${compactText(msg.senderName)}` : "";
|
|
64
64
|
const replies = msg.replyCount ? ` [${msg.replyCount} ${msg.replyCount === 1 ? "reply" : "replies"}]` : "";
|
|
65
|
-
|
|
65
|
+
// An invitation's answer in Wilma's own word ("Applied 29.05.2026 13:00").
|
|
66
|
+
const answer = msg.invitationStatus ? ` [${compactText([msg.invitationStatus, msg.invitationInfo].filter(Boolean).join(" "))}]` : "";
|
|
67
|
+
return `${finnishDay(msg.sentAt)} ${compactText(msg.subject)}${sender}${msg.unread ? " [new]" : ""}${replies}${answer} (id ${msg.wilmaId})`;
|
|
66
68
|
}
|
|
67
69
|
function lessonNoteLine(note, withDate) {
|
|
68
70
|
const date = withDate ? `${note.date} ` : "";
|
|
@@ -116,15 +118,27 @@ export function formatText(command, action, result) {
|
|
|
116
118
|
case "summary":
|
|
117
119
|
return perStudent(result, "summary", (s) => summaryLines(s));
|
|
118
120
|
case "schedule": {
|
|
119
|
-
const period = result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
|
|
121
|
+
const period = result.from ? `${result.from} – ${result.to}` : result.weekStart ? `${result.weekStart} – ${result.weekEnd}` : result.date;
|
|
120
122
|
return titled(`Schedule ${period}`, result, perStudent(result, "lessons", scheduleLines));
|
|
121
123
|
}
|
|
122
124
|
case "homework":
|
|
123
125
|
return perStudent(result, "homework", (items) => items.length ? items.map((h) => ` ${h.date} ${h.subject}: ${compactText(h.homework)}`) : [" No recent homework."]);
|
|
124
126
|
case "exams":
|
|
125
127
|
return perStudent(result, "exams", (items) => (items.length ? items.map((e) => ` ${examLine(e)}`) : [" No upcoming exams."]));
|
|
126
|
-
case "grades":
|
|
127
|
-
|
|
128
|
+
case "grades": {
|
|
129
|
+
const lines = (items) => items.length
|
|
130
|
+
? items.map((g) => ` ${g.date} ${g.subject}: ${g.name} — ${g.grade ?? "no grade yet"}${g.verbalGrade ? ` (${compactText(g.verbalGrade)})` : ""}`)
|
|
131
|
+
: [result.from ? " No exams in this period." : " No exam grades."];
|
|
132
|
+
const grades = perStudent(result, "grades", lines);
|
|
133
|
+
return result.from ? titled(`Exams ${result.from} – ${result.to}`, result, grades) : grades;
|
|
134
|
+
}
|
|
135
|
+
case "contacts":
|
|
136
|
+
return perStudent(result, "contacts", (c) => [
|
|
137
|
+
` Class: ${c.className ?? "not shown"}`,
|
|
138
|
+
...c.supervisors.map((s) => ` ${compactText(s.role)}: ${s.name}`),
|
|
139
|
+
...(c.teachers.length ? [" Teachers:", ...c.teachers.map((t) => ` ${t.name}${t.subjects.length ? ` — ${t.subjects.join(", ")}` : ""}`)] : []),
|
|
140
|
+
...(c.unavailable?.length ? [` (Not available from Wilma right now: ${c.unavailable.join(", ")})`] : []),
|
|
141
|
+
]);
|
|
128
142
|
case "gradebook":
|
|
129
143
|
return perStudent(result, "gradebook", (entries) => gradebookLines(entries));
|
|
130
144
|
case "notes": {
|
|
@@ -178,7 +192,7 @@ export function formatText(command, action, result) {
|
|
|
178
192
|
return perStudent(result, "printouts", (items) => items.length ? [...items.map((p) => ` ${compactText(p.title)} (id ${p.id})`)] : [" No printouts."]);
|
|
179
193
|
case "students":
|
|
180
194
|
return result.students
|
|
181
|
-
.map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}`)
|
|
195
|
+
.map((s) => `${s.studentNumber} ${s.name}${s.wilma ? ` (${s.wilma})` : ""}${s.role === "student" ? " [own login]" : ""}`)
|
|
182
196
|
.join("\n");
|
|
183
197
|
default:
|
|
184
198
|
return JSON.stringify(result, null, 2);
|
package/dist/config.d.ts
CHANGED
|
@@ -3,23 +3,53 @@ export interface StoredProfile {
|
|
|
3
3
|
tenantUrl: string;
|
|
4
4
|
tenantName?: string | null;
|
|
5
5
|
username: string;
|
|
6
|
-
|
|
6
|
+
/** Scrambled password: a login kept in this file (absent for a keychain login). */
|
|
7
|
+
passwordObfuscated?: string;
|
|
7
8
|
totpSecretObfuscated?: string | null;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
/** "keychain": the password and authenticator key are in the OS keychain, entry `secretId`. Absent: in this file. */
|
|
10
|
+
secrets?: "keychain" | string;
|
|
11
|
+
/** The login's keychain entry (random; also kept in CliConfig.keychainLogins). */
|
|
12
|
+
secretId?: string;
|
|
13
|
+
/** Random, new whenever the login's secrets change; not secret. Part of a keychain login's pool key. */
|
|
14
|
+
credentialsStamp?: string;
|
|
15
|
+
/** Whether an authenticator key is saved (so listings don't read the keychain). */
|
|
16
|
+
hasTotp?: boolean;
|
|
17
|
+
/** When moving the login to the keychain last failed (background moves wait a day). */
|
|
18
|
+
moveFailedAt?: string;
|
|
19
|
+
/** The children the login lists; `role` says whose login it is for each (a guardian's, or the student's own). */
|
|
20
|
+
students?: SavedStudent[];
|
|
21
|
+
/** Student numbers of children unticked on the login page (a profile Wilma lists twice): not read. */
|
|
22
|
+
hidden?: string[];
|
|
12
23
|
lastStudentNumber?: string | null;
|
|
13
24
|
lastStudentName?: string | null;
|
|
14
25
|
lastUsedAt: string;
|
|
15
26
|
}
|
|
27
|
+
export interface SavedStudent {
|
|
28
|
+
studentNumber: string;
|
|
29
|
+
name: string;
|
|
30
|
+
role?: "guardian" | "student";
|
|
31
|
+
}
|
|
32
|
+
/** A child as saved with a login: the number, the name, and the role when Wilma says it. */
|
|
33
|
+
export declare function savedStudent(s: SavedStudent): SavedStudent;
|
|
16
34
|
export interface CliConfig {
|
|
17
35
|
profiles: StoredProfile[];
|
|
18
36
|
lastProfileId?: string | null;
|
|
37
|
+
/** Where new secrets go: "file" (default), "auto" or "keychain" (`wilma config secrets`). */
|
|
38
|
+
secretStore?: string;
|
|
39
|
+
/** Every keychain entry this config made, by login id, written before the entry itself. */
|
|
40
|
+
keychainLogins?: Record<string, string>;
|
|
19
41
|
}
|
|
20
42
|
export declare function getConfigPath(): string;
|
|
21
43
|
export declare function loadConfig(): Promise<CliConfig>;
|
|
22
44
|
export declare function saveConfig(config: CliConfig): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Change the saved config from a fresh read, so a save never overwrites what
|
|
47
|
+
* another process wrote meanwhile (moves, logins, refreshed child lists).
|
|
48
|
+
* `edit` returns false to leave the file as it is.
|
|
49
|
+
*/
|
|
50
|
+
export declare function editConfig(edit: (config: CliConfig) => boolean | void | Promise<boolean | void>): Promise<CliConfig>;
|
|
51
|
+
/** The config as far as it can be read, or null (for `wilma config clear`, the way out of a damaged file). */
|
|
52
|
+
export declare function loadConfigLenient(): Promise<CliConfig | null>;
|
|
23
53
|
export declare function clearConfig(): Promise<void>;
|
|
24
54
|
export declare function obfuscateSecret(value: string): string;
|
|
25
|
-
export declare function revealSecret(value: string): string | null;
|
|
55
|
+
export declare function revealSecret(value: string | null | undefined): string | null;
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,10 @@ import { chmod, mkdir, readFile, rename, stat, writeFile, rm } from "node:fs/pro
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
/** A child as saved with a login: the number, the name, and the role when Wilma says it. */
|
|
6
|
+
export function savedStudent(s) {
|
|
7
|
+
return { studentNumber: s.studentNumber, name: s.name, ...(s.role ? { role: s.role } : {}) };
|
|
8
|
+
}
|
|
5
9
|
const SALT = "wilmai::";
|
|
6
10
|
export function getConfigPath() {
|
|
7
11
|
const override = process.env.WILMAI_CONFIG_PATH;
|
|
@@ -85,6 +89,26 @@ export async function saveConfig(config) {
|
|
|
85
89
|
await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
86
90
|
await rename(tmp, path);
|
|
87
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Change the saved config from a fresh read, so a save never overwrites what
|
|
94
|
+
* another process wrote meanwhile (moves, logins, refreshed child lists).
|
|
95
|
+
* `edit` returns false to leave the file as it is.
|
|
96
|
+
*/
|
|
97
|
+
export async function editConfig(edit) {
|
|
98
|
+
const config = await loadConfig();
|
|
99
|
+
if ((await edit(config)) !== false)
|
|
100
|
+
await saveConfig(config);
|
|
101
|
+
return config;
|
|
102
|
+
}
|
|
103
|
+
/** The config as far as it can be read, or null (for `wilma config clear`, the way out of a damaged file). */
|
|
104
|
+
export async function loadConfigLenient() {
|
|
105
|
+
try {
|
|
106
|
+
return await loadConfig();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
88
112
|
export async function clearConfig() {
|
|
89
113
|
const path = getConfigPath();
|
|
90
114
|
await rm(path, { force: true });
|
|
@@ -93,6 +117,8 @@ export function obfuscateSecret(value) {
|
|
|
93
117
|
return Buffer.from(SALT + value, "utf-8").toString("base64");
|
|
94
118
|
}
|
|
95
119
|
export function revealSecret(value) {
|
|
120
|
+
if (!value)
|
|
121
|
+
return null;
|
|
96
122
|
try {
|
|
97
123
|
const decoded = Buffer.from(value, "base64").toString("utf-8");
|
|
98
124
|
if (!decoded.startsWith(SALT)) {
|
package/dist/credentials.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { WilmaClient, type MfaCallback, type StudentInfo, type WilmaProfile } from "@wilm-ai/wilma-client";
|
|
2
2
|
import { type CliConfig, type StoredProfile } from "./config.js";
|
|
3
|
+
import { type LoginSecrets } from "./secret-store.js";
|
|
4
|
+
import { type SessionKeys } from "./session-keys.js";
|
|
3
5
|
/**
|
|
4
6
|
* Environment variables for agents that keep secrets in their own store
|
|
5
7
|
* (cloud VMs, CI-like sandboxes) instead of a config file.
|
|
@@ -11,8 +13,15 @@ export declare const ENV_VARS: {
|
|
|
11
13
|
readonly totpSecret: "WILMA_TOTP_SECRET";
|
|
12
14
|
};
|
|
13
15
|
export interface ActiveAccount {
|
|
16
|
+
/** For a keychain login the password is empty here: `secrets` reads it, only when Wilma needs a new login. */
|
|
14
17
|
profile: WilmaProfile;
|
|
15
18
|
totpSecret?: string;
|
|
19
|
+
/** A keychain login's (or an unreadable login's) password and authenticator key, read only when needed. */
|
|
20
|
+
secrets?: () => Promise<LoginSecrets>;
|
|
21
|
+
/** Whether an authenticator key is saved (for `secrets` logins, without reading it). */
|
|
22
|
+
hasTotp?: boolean;
|
|
23
|
+
/** Where its session is saved and pooled (v1 for file and environment logins, v2 for keychain logins). */
|
|
24
|
+
keys: SessionKeys;
|
|
16
25
|
/** Present when the account comes from the saved config. */
|
|
17
26
|
stored?: StoredProfile;
|
|
18
27
|
source: "config" | "env";
|
|
@@ -34,12 +43,18 @@ export declare function mfaCallbackFor(totpSecret?: string | null, clock?: {
|
|
|
34
43
|
now: () => number;
|
|
35
44
|
sleep: (ms: number) => Promise<void>;
|
|
36
45
|
}): MfaCallback | undefined;
|
|
46
|
+
/** A saved login as an account: its password from the file, or (keychain logins) read only when needed. */
|
|
47
|
+
export declare function accountFor(stored: StoredProfile): ActiveAccount;
|
|
48
|
+
/** The password and authenticator key of an account now (reading the keychain for a keychain login). */
|
|
49
|
+
export declare function accountSecrets(account: ActiveAccount): Promise<LoginSecrets>;
|
|
37
50
|
/** Environment variables win over the saved config when all three are set. */
|
|
38
51
|
export declare function resolveAccount(config: CliConfig, env?: NodeJS.ProcessEnv): Promise<ActiveAccount | null>;
|
|
39
52
|
/**
|
|
40
53
|
* Every Wilma login the family has. Environment variables give exactly one;
|
|
41
54
|
* otherwise all saved logins, the active one first. Families with children on
|
|
42
55
|
* different Wilmas (e.g. a city school and a private school) save one login each.
|
|
56
|
+
* A login whose password can't be read is kept: it fails with a clear message
|
|
57
|
+
* when used, instead of looking like no login at all.
|
|
43
58
|
*/
|
|
44
59
|
export declare function resolveAccounts(config: CliConfig, env?: NodeJS.ProcessEnv): Promise<ActiveAccount[]>;
|
|
45
60
|
/**
|
|
@@ -55,7 +70,32 @@ export declare function verifyLoginSession(input: {
|
|
|
55
70
|
client: WilmaClient;
|
|
56
71
|
students: StudentInfo[];
|
|
57
72
|
}>;
|
|
58
|
-
/**
|
|
73
|
+
/** Where a login's secrets go, decided before Wilma is contacted (section 5 of docs/secret-store.md). */
|
|
74
|
+
export interface StoreChoice {
|
|
75
|
+
store: "file" | "keychain";
|
|
76
|
+
/** The keychain entry to use (a login's own, or one this config already reserved for it). */
|
|
77
|
+
secretId?: string;
|
|
78
|
+
/** Said to the user: e.g. saved in the file because no keychain can be used here. */
|
|
79
|
+
note?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Decide where a login's secrets go and check the keychain can be used,
|
|
83
|
+
* before anything is sent to Wilma: a login that can't be saved then costs no
|
|
84
|
+
* Wilma login, no ended browser session and no used code. A login saved
|
|
85
|
+
* again keeps its store while that store can be used here; otherwise the
|
|
86
|
+
* setting decides (`auto` and `file`: the file; `keychain`: refused).
|
|
87
|
+
*/
|
|
88
|
+
export declare function chooseSecretStore(config: CliConfig, tenantUrl: string, username: string): Promise<StoreChoice>;
|
|
89
|
+
/**
|
|
90
|
+
* Save a verified login as the active profile, replacing any profile with the
|
|
91
|
+
* same tenant and username, in the store chosen by chooseSecretStore. A
|
|
92
|
+
* keychain entry is recorded in `keychainLogins` before it is written (reusing
|
|
93
|
+
* one another process reserved meanwhile), and read back before the login
|
|
94
|
+
* points at it. Under `auto`, a keychain that fails here falls back to the
|
|
95
|
+
* file (the returned note says so). The keychain calls come first; the
|
|
96
|
+
* profile is then saved onto a fresh read of the config, so a move or a login
|
|
97
|
+
* another process saved meanwhile isn't undone. `config` is updated to match.
|
|
98
|
+
*/
|
|
59
99
|
export declare function saveLogin(config: CliConfig, input: {
|
|
60
100
|
tenantUrl: string;
|
|
61
101
|
tenantName?: string | null;
|
|
@@ -63,7 +103,14 @@ export declare function saveLogin(config: CliConfig, input: {
|
|
|
63
103
|
password: string;
|
|
64
104
|
totpSecret?: string | null;
|
|
65
105
|
students: StudentInfo[];
|
|
66
|
-
}): Promise<StoredProfile
|
|
106
|
+
}, choice?: StoreChoice): Promise<StoredProfile & {
|
|
107
|
+
note?: string;
|
|
108
|
+
}>;
|
|
109
|
+
/**
|
|
110
|
+
* Save an authenticator key for a saved login (the interactive "Save TOTP
|
|
111
|
+
* secret"): into its keychain entry for a keychain login, never the file.
|
|
112
|
+
*/
|
|
113
|
+
export declare function saveTotpSecret(id: string, totpSecret: string): Promise<void>;
|
|
67
114
|
/** The client reports a rejected one-time code as an AuthenticationError starting "MFA verification failed". */
|
|
68
115
|
export declare function isMfaFailure(err: unknown): boolean;
|
|
69
116
|
export { AuthenticationError } from "@wilm-ai/wilma-client";
|
package/dist/credentials.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { AuthenticationError, MfaRequiredError, WilmaClient, } from "@wilm-ai/wilma-client";
|
|
2
|
-
import { obfuscateSecret, revealSecret,
|
|
2
|
+
import { editConfig, loadConfig, obfuscateSecret, revealSecret, savedStudent, } from "./config.js";
|
|
3
|
+
import { effectiveSecretStore, keychainProblem, newCredentialsStamp, newSecretId, readBackLoginSecrets, readLoginSecrets, SecretStoreError, secretErrorMessage, writeLoginSecrets, } from "./secret-store.js";
|
|
4
|
+
import { fileLoginKeys, keychainLoginKeys } from "./session-keys.js";
|
|
3
5
|
import { generateTOTP, parseTotpSecret, TOTP_PERIOD_MS, totpCounter } from "./totp.js";
|
|
4
6
|
import { normalizeTenantUrl, resolveTenant } from "./tenant-search.js";
|
|
5
7
|
/**
|
|
@@ -67,43 +69,75 @@ export function mfaCallbackFor(totpSecret, clock = {
|
|
|
67
69
|
function hasEnvAccount(env = process.env) {
|
|
68
70
|
return Boolean(env[ENV_VARS.tenant] && env[ENV_VARS.username] && env[ENV_VARS.password]);
|
|
69
71
|
}
|
|
72
|
+
/** A saved login as an account: its password from the file, or (keychain logins) read only when needed. */
|
|
73
|
+
export function accountFor(stored) {
|
|
74
|
+
const profile = {
|
|
75
|
+
baseUrl: stored.tenantUrl,
|
|
76
|
+
username: stored.username,
|
|
77
|
+
password: "",
|
|
78
|
+
studentNumber: stored.lastStudentNumber ?? undefined,
|
|
79
|
+
};
|
|
80
|
+
const wilma = stored.tenantName ?? stored.tenantUrl;
|
|
81
|
+
const base = { stored, source: "config" };
|
|
82
|
+
if (stored.secrets === "keychain" && stored.secretId) {
|
|
83
|
+
const ref = { secretId: stored.secretId, credentialsStamp: stored.credentialsStamp, wilma };
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
profile,
|
|
87
|
+
secrets: () => readLoginSecrets(ref),
|
|
88
|
+
hasTotp: Boolean(stored.hasTotp),
|
|
89
|
+
keys: keychainLoginKeys(stored.tenantUrl, stored.username, stored.credentialsStamp),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (stored.secrets) {
|
|
93
|
+
// Kept somewhere this version doesn't know (a newer WilmAI saved it).
|
|
94
|
+
const error = new SecretStoreError("unavailable", secretErrorMessage("unavailable", wilma));
|
|
95
|
+
return { ...base, profile, secrets: () => Promise.reject(error), keys: keychainLoginKeys(stored.tenantUrl, stored.username, stored.credentialsStamp) };
|
|
96
|
+
}
|
|
97
|
+
const password = revealSecret(stored.passwordObfuscated);
|
|
98
|
+
if (!password) {
|
|
99
|
+
// Reported when used, not skipped: a skipped login looks like "not logged in" and invites a second login.
|
|
100
|
+
const error = new SecretStoreError("missing", `The saved password for ${wilma} can't be read. Log in again (\`wilma login\`).`);
|
|
101
|
+
return { ...base, profile, secrets: () => Promise.reject(error), keys: keychainLoginKeys(stored.tenantUrl, stored.username, null) };
|
|
102
|
+
}
|
|
103
|
+
const totpSecret = revealSecret(stored.totpSecretObfuscated) ?? undefined;
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
profile: { ...profile, password },
|
|
107
|
+
totpSecret,
|
|
108
|
+
hasTotp: Boolean(totpSecret),
|
|
109
|
+
keys: fileLoginKeys(stored.tenantUrl, stored.username, password),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** The password and authenticator key of an account now (reading the keychain for a keychain login). */
|
|
113
|
+
export async function accountSecrets(account) {
|
|
114
|
+
if (account.secrets)
|
|
115
|
+
return account.secrets();
|
|
116
|
+
return { password: account.profile.password, totpSecret: account.totpSecret ?? null };
|
|
117
|
+
}
|
|
70
118
|
/** Environment variables win over the saved config when all three are set. */
|
|
71
119
|
export async function resolveAccount(config, env = process.env) {
|
|
72
120
|
if (hasEnvAccount(env)) {
|
|
73
121
|
const tenant = await resolveTenant(env[ENV_VARS.tenant]);
|
|
122
|
+
const username = env[ENV_VARS.username];
|
|
123
|
+
const password = env[ENV_VARS.password];
|
|
74
124
|
return {
|
|
75
|
-
profile: {
|
|
76
|
-
baseUrl: tenant.url,
|
|
77
|
-
username: env[ENV_VARS.username],
|
|
78
|
-
password: env[ENV_VARS.password],
|
|
79
|
-
},
|
|
125
|
+
profile: { baseUrl: tenant.url, username, password },
|
|
80
126
|
totpSecret: env[ENV_VARS.totpSecret] || undefined,
|
|
127
|
+
hasTotp: Boolean(env[ENV_VARS.totpSecret]),
|
|
128
|
+
keys: fileLoginKeys(tenant.url, username, password),
|
|
81
129
|
source: "env",
|
|
82
130
|
};
|
|
83
131
|
}
|
|
84
132
|
const stored = config.profiles.find((p) => p.id === config.lastProfileId);
|
|
85
|
-
|
|
86
|
-
return null;
|
|
87
|
-
const password = revealSecret(stored.passwordObfuscated);
|
|
88
|
-
if (!password) {
|
|
89
|
-
throw new Error("Stored password could not be decoded. Run `wilma login` again.");
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
profile: {
|
|
93
|
-
baseUrl: stored.tenantUrl,
|
|
94
|
-
username: stored.username,
|
|
95
|
-
password,
|
|
96
|
-
studentNumber: stored.lastStudentNumber ?? undefined,
|
|
97
|
-
},
|
|
98
|
-
totpSecret: stored.totpSecretObfuscated ? revealSecret(stored.totpSecretObfuscated) ?? undefined : undefined,
|
|
99
|
-
stored,
|
|
100
|
-
source: "config",
|
|
101
|
-
};
|
|
133
|
+
return stored ? accountFor(stored) : null;
|
|
102
134
|
}
|
|
103
135
|
/**
|
|
104
136
|
* Every Wilma login the family has. Environment variables give exactly one;
|
|
105
137
|
* otherwise all saved logins, the active one first. Families with children on
|
|
106
138
|
* different Wilmas (e.g. a city school and a private school) save one login each.
|
|
139
|
+
* A login whose password can't be read is kept: it fails with a clear message
|
|
140
|
+
* when used, instead of looking like no login at all.
|
|
107
141
|
*/
|
|
108
142
|
export async function resolveAccounts(config, env = process.env) {
|
|
109
143
|
if (hasEnvAccount(env)) {
|
|
@@ -119,20 +153,7 @@ export async function resolveAccounts(config, env = process.env) {
|
|
|
119
153
|
if (seen.has(key))
|
|
120
154
|
continue;
|
|
121
155
|
seen.add(key);
|
|
122
|
-
|
|
123
|
-
if (!password)
|
|
124
|
-
continue;
|
|
125
|
-
accounts.push({
|
|
126
|
-
profile: {
|
|
127
|
-
baseUrl: stored.tenantUrl,
|
|
128
|
-
username: stored.username,
|
|
129
|
-
password,
|
|
130
|
-
studentNumber: stored.lastStudentNumber ?? undefined,
|
|
131
|
-
},
|
|
132
|
-
totpSecret: stored.totpSecretObfuscated ? revealSecret(stored.totpSecretObfuscated) ?? undefined : undefined,
|
|
133
|
-
stored,
|
|
134
|
-
source: "config",
|
|
135
|
-
});
|
|
156
|
+
accounts.push(accountFor(stored));
|
|
136
157
|
}
|
|
137
158
|
return accounts;
|
|
138
159
|
}
|
|
@@ -155,37 +176,151 @@ export async function verifyLoginSession(input) {
|
|
|
155
176
|
throw err;
|
|
156
177
|
}
|
|
157
178
|
}
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
const sameAccountAs = (tenantUrl, username) => (p) => normalizeTenantUrl(p.tenantUrl) === normalizeTenantUrl(tenantUrl) && p.username.toLowerCase() === username.toLowerCase();
|
|
180
|
+
const FILE_FALLBACK_NOTE = "Saved in WilmAI's file on this computer: there is no keychain WilmAI can use here.";
|
|
181
|
+
/**
|
|
182
|
+
* Decide where a login's secrets go and check the keychain can be used,
|
|
183
|
+
* before anything is sent to Wilma: a login that can't be saved then costs no
|
|
184
|
+
* Wilma login, no ended browser session and no used code. A login saved
|
|
185
|
+
* again keeps its store while that store can be used here; otherwise the
|
|
186
|
+
* setting decides (`auto` and `file`: the file; `keychain`: refused).
|
|
187
|
+
*/
|
|
188
|
+
export async function chooseSecretStore(config, tenantUrl, username) {
|
|
189
|
+
const setting = effectiveSecretStore(config.secretStore);
|
|
190
|
+
const previous = config.profiles.find(sameAccountAs(tenantUrl, username));
|
|
191
|
+
const id = previous?.id ?? `${normalizeTenantUrl(tenantUrl)}|${username}`;
|
|
192
|
+
const reserved = config.keychainLogins?.[id];
|
|
193
|
+
// A saved keychain login stays in the keychain; a saved file login moves only by the saved setting (the
|
|
194
|
+
// environment variable decides only where new logins go); a new login follows the setting in effect.
|
|
195
|
+
const wantsKeychain = previous?.secrets === "keychain" ||
|
|
196
|
+
(previous ? effectiveSecretStore(config.secretStore, {}) !== "file" : setting !== "file");
|
|
197
|
+
if (!wantsKeychain)
|
|
198
|
+
return { store: "file" };
|
|
199
|
+
const problem = await keychainProblem();
|
|
200
|
+
if (!problem)
|
|
201
|
+
return { store: "keychain", secretId: previous?.secretId ?? reserved ?? newSecretId() };
|
|
202
|
+
if (setting === "keychain") {
|
|
203
|
+
throw new SecretStoreError("unavailable", `WilmAI is set to keep passwords in the keychain, but there's no keychain it can use on this computer (${problem}). Nothing was sent to Wilma.`);
|
|
204
|
+
}
|
|
205
|
+
return { store: "file", note: FILE_FALLBACK_NOTE };
|
|
206
|
+
}
|
|
207
|
+
/** The authenticator key a login already has, so logging in again without one keeps it. */
|
|
208
|
+
async function previousTotp(previous) {
|
|
209
|
+
if (!previous)
|
|
210
|
+
return null;
|
|
211
|
+
if (previous.secrets === "keychain" && previous.secretId && previous.hasTotp) {
|
|
212
|
+
return (await readBackLoginSecrets(previous.secretId).catch(() => null))?.totpSecret ?? null;
|
|
213
|
+
}
|
|
214
|
+
return revealSecret(previous.totpSecretObfuscated);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Save a verified login as the active profile, replacing any profile with the
|
|
218
|
+
* same tenant and username, in the store chosen by chooseSecretStore. A
|
|
219
|
+
* keychain entry is recorded in `keychainLogins` before it is written (reusing
|
|
220
|
+
* one another process reserved meanwhile), and read back before the login
|
|
221
|
+
* points at it. Under `auto`, a keychain that fails here falls back to the
|
|
222
|
+
* file (the returned note says so). The keychain calls come first; the
|
|
223
|
+
* profile is then saved onto a fresh read of the config, so a move or a login
|
|
224
|
+
* another process saved meanwhile isn't undone. `config` is updated to match.
|
|
225
|
+
*/
|
|
226
|
+
export async function saveLogin(config, input, choice = { store: "file" }) {
|
|
160
227
|
const tenantUrl = normalizeTenantUrl(input.tenantUrl);
|
|
161
228
|
// Wilma usernames are case-insensitive: "Matti.M" and "matti.m" are one account
|
|
162
229
|
// (saving it twice would make two sessions that keep cancelling each other).
|
|
163
|
-
const sameAccount = (
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
230
|
+
const sameAccount = sameAccountAs(tenantUrl, input.username);
|
|
231
|
+
const id = config.profiles.find(sameAccount)?.id ?? `${tenantUrl}|${input.username}`;
|
|
232
|
+
const totpSecret = input.totpSecret?.trim() || (await previousTotp(config.profiles.find(sameAccount)));
|
|
233
|
+
const secrets = { password: input.password, totpSecret };
|
|
234
|
+
let note = choice.note;
|
|
235
|
+
let secretFields;
|
|
236
|
+
let secretId;
|
|
237
|
+
if (choice.store === "keychain") {
|
|
238
|
+
try {
|
|
239
|
+
// Recorded before the entry exists, so no entry is ever unknown to the config.
|
|
240
|
+
await editConfig((fresh) => {
|
|
241
|
+
secretId = fresh.keychainLogins?.[id] ?? choice.secretId ?? newSecretId();
|
|
242
|
+
if (fresh.keychainLogins?.[id] === secretId)
|
|
243
|
+
return false;
|
|
244
|
+
fresh.keychainLogins = { ...fresh.keychainLogins, [id]: secretId };
|
|
245
|
+
});
|
|
246
|
+
await writeLoginSecrets(secretId, secrets);
|
|
247
|
+
const back = await readBackLoginSecrets(secretId);
|
|
248
|
+
if (back?.password !== secrets.password || (back?.totpSecret ?? null) !== (secrets.totpSecret ?? null)) {
|
|
249
|
+
throw new SecretStoreError("refused", "The keychain didn't keep the password as saved.");
|
|
250
|
+
}
|
|
251
|
+
secretFields = { secrets: "keychain", secretId, hasTotp: Boolean(totpSecret) };
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
// `keychain` never quietly saves to the file; `auto` and `file` do, and say so.
|
|
255
|
+
if (effectiveSecretStore(config.secretStore) === "keychain")
|
|
256
|
+
throw err;
|
|
257
|
+
note = FILE_FALLBACK_NOTE;
|
|
258
|
+
secretFields = fileSecretFields(secrets);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
secretFields = fileSecretFields(secrets);
|
|
263
|
+
}
|
|
264
|
+
let stored;
|
|
265
|
+
const fresh = await editConfig((latest) => {
|
|
266
|
+
const previous = latest.profiles.find(sameAccount);
|
|
267
|
+
const keepLast = previous?.lastStudentNumber
|
|
268
|
+
? input.students.find((s) => s.studentNumber === previous.lastStudentNumber)
|
|
269
|
+
: undefined;
|
|
270
|
+
const lastStudent = keepLast ?? input.students[0];
|
|
271
|
+
stored = {
|
|
272
|
+
id: previous?.id ?? id,
|
|
273
|
+
tenantUrl,
|
|
274
|
+
tenantName: input.tenantName ?? previous?.tenantName ?? tenantUrl,
|
|
275
|
+
username: input.username,
|
|
276
|
+
...secretFields,
|
|
277
|
+
credentialsStamp: newCredentialsStamp(),
|
|
278
|
+
students: input.students.map(savedStudent),
|
|
279
|
+
lastStudentNumber: lastStudent?.studentNumber ?? null,
|
|
280
|
+
lastStudentName: lastStudent?.name ?? null,
|
|
281
|
+
lastUsedAt: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
// Logging in again keeps the login's place in the list (`wilma accounts` numbers stay put).
|
|
284
|
+
const at = latest.profiles.findIndex(sameAccount);
|
|
285
|
+
const others = latest.profiles.filter((p) => !sameAccount(p));
|
|
286
|
+
latest.profiles = at === -1 ? [...others, stored] : [...others.slice(0, at), stored, ...others.slice(at)];
|
|
287
|
+
latest.lastProfileId = stored.id;
|
|
288
|
+
});
|
|
289
|
+
Object.assign(config, fresh);
|
|
290
|
+
return note ? { ...stored, note } : stored;
|
|
291
|
+
}
|
|
292
|
+
function fileSecretFields(secrets) {
|
|
293
|
+
return {
|
|
294
|
+
passwordObfuscated: obfuscateSecret(secrets.password),
|
|
295
|
+
totpSecretObfuscated: secrets.totpSecret ? obfuscateSecret(secrets.totpSecret.trim()) : null,
|
|
296
|
+
hasTotp: Boolean(secrets.totpSecret),
|
|
181
297
|
};
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Save an authenticator key for a saved login (the interactive "Save TOTP
|
|
301
|
+
* secret"): into its keychain entry for a keychain login, never the file.
|
|
302
|
+
*/
|
|
303
|
+
export async function saveTotpSecret(id, totpSecret) {
|
|
304
|
+
const key = totpSecret.trim();
|
|
305
|
+
const target = (await loadConfig()).profiles.find((p) => p.id === id);
|
|
306
|
+
if (target?.secrets === "keychain" && target.secretId) {
|
|
307
|
+
const current = await readBackLoginSecrets(target.secretId);
|
|
308
|
+
if (!current)
|
|
309
|
+
throw new SecretStoreError("missing", secretErrorMessage("missing", target.tenantName));
|
|
310
|
+
await writeLoginSecrets(target.secretId, { ...current, totpSecret: key });
|
|
311
|
+
}
|
|
312
|
+
await editConfig((config) => {
|
|
313
|
+
const profile = config.profiles.find((p) => p.id === id);
|
|
314
|
+
if (!profile)
|
|
315
|
+
return false;
|
|
316
|
+
if (profile.secrets === "keychain")
|
|
317
|
+
profile.hasTotp = true;
|
|
318
|
+
else {
|
|
319
|
+
profile.totpSecretObfuscated = obfuscateSecret(key);
|
|
320
|
+
profile.hasTotp = true;
|
|
321
|
+
}
|
|
322
|
+
profile.credentialsStamp = newCredentialsStamp();
|
|
323
|
+
});
|
|
189
324
|
}
|
|
190
325
|
/** The client reports a rejected one-time code as an AuthenticationError starting "MFA verification failed". */
|
|
191
326
|
export function isMfaFailure(err) {
|