@patimweb/pi-teams 1.0.0 → 1.1.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 +112 -28
- package/package.json +3 -2
- package/prompts/teams-doctor.md +9 -2
- package/skills/teams-collaboration/SKILL.md +4 -2
- package/src/auth/cache-plugin.ts +215 -0
- package/src/auth/index.ts +301 -54
- package/src/auth/msal.ts +168 -0
- package/src/config/index.ts +52 -18
- package/src/safety/index.ts +3 -3
- package/src/status.ts +21 -7
- package/src/tools/teams-accounts.ts +22 -17
- package/src/tools/teams-doctor.ts +36 -24
- package/src/tools/teams-login.ts +111 -60
- package/src/tools/teams-setup.ts +12 -2
- package/src/tools/teams-status.ts +8 -8
- package/src/utils/environment.ts +41 -0
- package/src/auth/client-credentials.ts +0 -72
- package/src/auth/device-code.ts +0 -276
- package/src/auth/token-store.ts +0 -156
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ pi install /path/to/pi-teams
|
|
|
28
28
|
clientId: <application (client) ID>
|
|
29
29
|
setDefault: true
|
|
30
30
|
```
|
|
31
|
-
3. **Sign in:** run `/teams-login`.
|
|
32
|
-
|
|
31
|
+
3. **Sign in:** run `/teams-login`. Your browser opens at the Microsoft
|
|
32
|
+
sign-in page and closes itself when you are done — nothing to type.
|
|
33
33
|
4. **Check it works:** `/teams-status`, then `/teams-inbox`.
|
|
34
34
|
|
|
35
35
|
---
|
|
@@ -76,41 +76,122 @@ Without this, sign-in fails with `AADSTS7000218`.
|
|
|
76
76
|
| `Calendars.ReadWrite` | reading and creating calendar events |
|
|
77
77
|
| `OnlineMeetings.ReadWrite` | Teams meeting links |
|
|
78
78
|
| `Files.Read.All`, `Sites.Read.All` | channel files |
|
|
79
|
-
| `offline_access` | staying signed in |
|
|
80
79
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
`openid`, `profile` and `offline_access` are not listed: MSAL always requests
|
|
81
|
+
them and rejects them in an explicit scope list, so the lasting sign-in they
|
|
82
|
+
buy is automatic.
|
|
84
83
|
|
|
85
|
-
|
|
84
|
+
### 4. Add the redirect URI for the browser sign-in
|
|
86
85
|
|
|
87
|
-
|
|
86
|
+
**Authentication** → **Add a platform** → **Mobile and desktop applications** →
|
|
87
|
+
tick `http://localhost`.
|
|
88
|
+
|
|
89
|
+
MSAL picks a free loopback port at sign-in time, and Entra accepts any port on
|
|
90
|
+
localhost for a public client, so this one entry covers every run. If your
|
|
91
|
+
tenant insists on an exact URI, register e.g. `http://localhost:3000` and set
|
|
92
|
+
`loopbackPort: 3000` on the account.
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## Who has to approve what
|
|
97
|
+
|
|
98
|
+
Two separate hurdles, and only one of them normally needs an administrator.
|
|
99
|
+
|
|
100
|
+
### Creating the registration
|
|
101
|
+
|
|
102
|
+
By default any member of the tenant may register an application (Entra ID →
|
|
103
|
+
**User settings** → *Users can register applications*). Where that is switched
|
|
104
|
+
off, the least-privileged role that can do it is **Application Developer**;
|
|
105
|
+
Cloud Application Administrator and Application Administrator also work.
|
|
106
|
+
Changing *Allow public client flows* and adding the redirect URI need no extra
|
|
107
|
+
role once the registration is yours.
|
|
108
|
+
|
|
109
|
+
### Consenting to the permissions
|
|
110
|
+
|
|
111
|
+
Only two of the default scopes require **admin consent**:
|
|
112
|
+
|
|
113
|
+
| Scope | Consent | What you lose without it |
|
|
114
|
+
|-------|---------|--------------------------|
|
|
115
|
+
| `ChannelMessage.Read.All` | **admin** | reading channel messages and threads |
|
|
116
|
+
| `ChannelMember.Read.All` | **admin** | channel member lists |
|
|
117
|
+
| `ChannelMessage.Send` | user | — |
|
|
118
|
+
| `Chat.ReadWrite`, `ChatMessage.Send` | user | — |
|
|
119
|
+
| `Team.ReadBasic.All`, `Channel.ReadBasic.All` | user | — |
|
|
120
|
+
| `User.Read`, `User.ReadBasic.All` | user | — |
|
|
121
|
+
| `Presence.ReadWrite`, `Presence.Read.All` | user | — |
|
|
122
|
+
| `Calendars.ReadWrite`, `OnlineMeetings.ReadWrite` | user | — |
|
|
123
|
+
| `Files.Read.All`, `Sites.Read.All` | user | — |
|
|
124
|
+
|
|
125
|
+
Posting in a channel needs no admin; *reading* the channel does. Microsoft
|
|
126
|
+
treats bulk reading of channel content as the more sensitive right.
|
|
127
|
+
|
|
128
|
+
**Without an administrator**, drop the two admin-consent scopes and everything
|
|
129
|
+
else still works — chats in full, posting and replying in channels, presence,
|
|
130
|
+
calendar, meetings and directory lookup:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
"scopes": [
|
|
134
|
+
"User.Read", "User.ReadBasic.All",
|
|
135
|
+
"Team.ReadBasic.All", "Channel.ReadBasic.All", "ChannelMessage.Send",
|
|
136
|
+
"Chat.ReadWrite", "ChatMessage.Send",
|
|
137
|
+
"Presence.ReadWrite", "Presence.Read.All",
|
|
138
|
+
"Calendars.ReadWrite", "OnlineMeetings.ReadWrite",
|
|
139
|
+
"Files.Read.All", "Sites.Read.All"
|
|
140
|
+
]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
One caveat: if the tenant has *Users can consent to applications* switched off,
|
|
144
|
+
even the user-consentable scopes need an administrator — usually through the
|
|
145
|
+
admin consent request workflow.
|
|
146
|
+
|
|
147
|
+
`teams_doctor` reports exactly which scopes were actually granted, so a missing
|
|
148
|
+
consent shows up as a named permission rather than a 403.
|
|
149
|
+
|
|
150
|
+
### Guest and customer tenants
|
|
88
151
|
|
|
89
152
|
For a tenant that is not your own:
|
|
90
153
|
|
|
91
154
|
- the registration must be **multitenant**,
|
|
92
155
|
- your account must be a guest in that tenant,
|
|
93
|
-
- an administrator
|
|
156
|
+
- an administrator **of that tenant** consents to the app once.
|
|
94
157
|
|
|
95
|
-
|
|
96
|
-
|
|
158
|
+
In practice this is the bigger hurdle, not your own tenant. Once it is done,
|
|
159
|
+
add the tenant beneath your account (`parentAccount` in `teams_setup`) and sign
|
|
160
|
+
in against it.
|
|
97
161
|
|
|
98
162
|
---
|
|
99
163
|
|
|
100
|
-
##
|
|
164
|
+
## How the sign-in works
|
|
165
|
+
|
|
166
|
+
`teams_login` uses MSAL (`@azure/msal-node`) and picks the flow that fits the
|
|
167
|
+
machine:
|
|
168
|
+
|
|
169
|
+
| | `interactive` (default) | `device-code` | `client-credentials` |
|
|
170
|
+
|---|---|---|---|
|
|
171
|
+
| What you do | browser opens, you sign in | type a short code elsewhere | nothing |
|
|
172
|
+
| Identity | you | you | the application |
|
|
173
|
+
| Needs a browser | yes | no | no |
|
|
174
|
+
| Reading chats/channels | yes | yes | only via protected APIs |
|
|
175
|
+
| **Sending messages** | **yes** | **yes** | **no** — refused with an explanation |
|
|
176
|
+
| Presence, calendar | yes | yes | partially |
|
|
177
|
+
|
|
178
|
+
**Interactive** is chosen automatically wherever a browser can actually be
|
|
179
|
+
reached. MSAL opens the system browser, listens on a loopback port for the
|
|
180
|
+
redirect, and handles PKCE — nothing to copy or type.
|
|
181
|
+
|
|
182
|
+
**Device code** takes over automatically over SSH, in containers, and on any
|
|
183
|
+
machine without a display server, because a browser opened there would appear
|
|
184
|
+
where nobody is looking. Force it anywhere with `PI_TEAMS_NO_BROWSER=1`, per
|
|
185
|
+
account with `"authMode": "device-code"`, or per call with
|
|
186
|
+
`teams_login mode: device-code`.
|
|
101
187
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
| Identity | you | the application |
|
|
105
|
-
| Sign-in | once, interactive | none |
|
|
106
|
-
| Reading chats/channels | yes | only via protected APIs |
|
|
107
|
-
| **Sending messages** | **yes** | **no** — refused with an explanation |
|
|
108
|
-
| Presence, calendar | yes | partially |
|
|
188
|
+
Either way the session is cached on disk and renewed silently, so signing in is
|
|
189
|
+
a once-per-account affair.
|
|
109
190
|
|
|
110
191
|
Microsoft Graph only permits app-only writes to chats and channels for
|
|
111
192
|
migration scenarios (`Teamwork.Migrate.All`), so pi-teams refuses those calls
|
|
112
193
|
up front instead of letting them fail deep inside a Graph request. Use
|
|
113
|
-
`client-credentials` for unattended read-only jobs,
|
|
194
|
+
`client-credentials` for unattended read-only jobs, and a delegated flow for
|
|
114
195
|
everything that speaks in your name.
|
|
115
196
|
|
|
116
197
|
---
|
|
@@ -129,7 +210,7 @@ prefer.
|
|
|
129
210
|
"displayName": "NeoImpulse",
|
|
130
211
|
"tenantId": "neoimpulse.onmicrosoft.com",
|
|
131
212
|
"clientId": "00000000-0000-0000-0000-000000000000",
|
|
132
|
-
"authMode": "
|
|
213
|
+
"authMode": "interactive",
|
|
133
214
|
"safetyLevel": "confirm",
|
|
134
215
|
"permissions": {
|
|
135
216
|
"read": {
|
|
@@ -250,7 +331,7 @@ teams_permissions:
|
|
|
250
331
|
| Tool | Description |
|
|
251
332
|
|------|-------------|
|
|
252
333
|
| `teams_setup` | Write an account or tenant to the config file |
|
|
253
|
-
| `teams_login` | Sign in as yourself (device code
|
|
334
|
+
| `teams_login` | Sign in as yourself (browser, or device code where there is none) |
|
|
254
335
|
| `teams_logout` | Remove a saved session |
|
|
255
336
|
| `teams_accounts` | List accounts, switch the default, change a safety level, delete |
|
|
256
337
|
| `teams_status` | Which account is active, who pi acts as, what it may do |
|
|
@@ -298,7 +379,7 @@ teams_permissions:
|
|
|
298
379
|
| Command | Description |
|
|
299
380
|
|---------|-------------|
|
|
300
381
|
| `/teams-status` | Show the connection and permissions |
|
|
301
|
-
| `/teams-login` |
|
|
382
|
+
| `/teams-login` | Sign in to Teams as yourself |
|
|
302
383
|
| `/teams-inbox` | What needs your attention right now |
|
|
303
384
|
| `/teams-permissions` | What pi may and may not do |
|
|
304
385
|
| `/teams-doctor` | Diagnose the setup |
|
|
@@ -354,7 +435,7 @@ teams_send_channel_message:
|
|
|
354
435
|
| File | Contents | Mode |
|
|
355
436
|
|------|----------|------|
|
|
356
437
|
| `~/.pi/agent/pi-teams.json` | accounts, tenants, safety levels, scope rules | `0600` |
|
|
357
|
-
| `~/.pi/agent/pi-teams-tokens
|
|
438
|
+
| `~/.pi/agent/pi-teams-tokens/` | one MSAL token cache per account+tenant | `0600` |
|
|
358
439
|
| `~/.pi/agent/pi-teams-audit.jsonl` | one line per write: when, who, where, what | `0600` |
|
|
359
440
|
|
|
360
441
|
Both the config and the token cache are written through a private temp file and
|
|
@@ -371,7 +452,7 @@ middle.
|
|
|
371
452
|
- **`src/config/index.ts`** — accounts, tenants, the safety cascade, persistence.
|
|
372
453
|
- **`src/config/scope.ts`** — allow/deny matching. Pure, and the most heavily
|
|
373
454
|
tested module in the package.
|
|
374
|
-
- **`src/auth/`** —
|
|
455
|
+
- **`src/auth/`** — MSAL applications, the file-backed token cache, JWT claims.
|
|
375
456
|
- **`src/graph/client.ts`** — fetch wrapper: bearer token, paging, throttling.
|
|
376
457
|
- **`src/graph/*.ts`** — one module per resource area, returning domain types.
|
|
377
458
|
- **`src/graph/mappers.ts`** — Graph JSON → domain types. Pure.
|
|
@@ -398,11 +479,14 @@ a network connection.
|
|
|
398
479
|
|---------|---------------|
|
|
399
480
|
| `AADSTS7000218` | "Allow public client flows" is off in the app registration |
|
|
400
481
|
| `AADSTS65001` | Consent missing — sign in again, or have an admin consent |
|
|
482
|
+
| `AADSTS50011` (redirect URI mismatch) | Add `http://localhost` under *Mobile and desktop applications*, or pin `loopbackPort` |
|
|
401
483
|
| `AADSTS50020` | The account is not a member/guest of that tenant, or the app is single-tenant |
|
|
402
|
-
|
|
|
484
|
+
| Browser never opens | No browser is reachable here; `teams_doctor` says why. `teams_login mode: device-code` always works |
|
|
485
|
+
| Browser opens, page never returns | A firewall is blocking the loopback port. Pin `loopbackPort` and allow it, or use the device code flow |
|
|
486
|
+
| "Not signed in" | Run `teams_login`; the saved session may have been revoked |
|
|
403
487
|
| "Blocked by configuration" | Your own scope rules. `teams_permissions action: test` shows which rule |
|
|
404
|
-
| "cannot run on an app-only token" | Set `authMode: "
|
|
405
|
-
| 403
|
|
488
|
+
| "cannot run on an app-only token" | Set `authMode: "interactive"` and run `teams_login` |
|
|
489
|
+
| 403 reading channel messages | `ChannelMessage.Read.All` needs **admin** consent — see "Who has to approve what" |
|
|
406
490
|
| Throttled | Graph rate limit; the client retries with back-off, then reports it |
|
|
407
491
|
|
|
408
492
|
Run `teams_doctor` first — it checks all of the above at once.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patimweb/pi-teams",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Microsoft Teams integration for pi coding agent — acts fully as the signed-in user across multiple accounts and tenants: chats, channels, meetings, presence, with configurable guardrails",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"typecheck": "tsc --noEmit"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"
|
|
32
|
+
"@azure/msal-node": "^6.0.0",
|
|
33
|
+
"typebox": ">=1.0.0"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"@earendil-works/pi-coding-agent": ">=0.70.0",
|
package/prompts/teams-doctor.md
CHANGED
|
@@ -7,12 +7,19 @@ Something is wrong with my Teams connection.
|
|
|
7
7
|
|
|
8
8
|
2. Explain each problem in plain language, and say who can fix it — me or a
|
|
9
9
|
tenant administrator. In particular:
|
|
10
|
-
- missing consent → name the exact Graph scope and what it is needed for
|
|
10
|
+
- missing consent → name the exact Graph scope and what it is needed for,
|
|
11
|
+
and say whether it needs an admin (`ChannelMessage.Read.All` and
|
|
12
|
+
`ChannelMember.Read.All` do; the rest I can consent to myself)
|
|
11
13
|
- "Allow public client flows" disabled → the setting is in Entra ID →
|
|
12
14
|
App registrations → Authentication
|
|
15
|
+
- a redirect URI mismatch → `http://localhost` must be registered under
|
|
16
|
+
"Mobile and desktop applications"
|
|
17
|
+
- the browser sign-in unavailable → say why, and that
|
|
18
|
+
`teams_login mode: device-code` works anywhere
|
|
13
19
|
- expired or revoked sign-in → I need to run `teams_login` again
|
|
14
20
|
- a guest tenant that rejects the sign-in → the app registration must be
|
|
15
|
-
multi-tenant
|
|
21
|
+
multi-tenant, I must be invited there, and an admin of that tenant must
|
|
22
|
+
have consented
|
|
16
23
|
|
|
17
24
|
3. Run `teams_permissions` with `action: "show"` and tell me, in one short
|
|
18
25
|
paragraph, what you are currently allowed to do and what you are not.
|
|
@@ -12,8 +12,10 @@ something the user is personally saying.
|
|
|
12
12
|
## Before anything else
|
|
13
13
|
|
|
14
14
|
Run `teams_status` if you are unsure which account is active or whether pi is
|
|
15
|
-
signed in. If it reports "not signed in", run `teams_login
|
|
16
|
-
|
|
15
|
+
signed in. If it reports "not signed in", run `teams_login`: it opens the
|
|
16
|
+
user's browser and completes on its own. On a machine with no browser it falls
|
|
17
|
+
back to a device code — then show the code and the URL to the user verbatim,
|
|
18
|
+
because they cannot finish signing in without them.
|
|
17
19
|
|
|
18
20
|
If a tool fails with an authentication, permission or configuration error, run
|
|
19
21
|
`teams_doctor` and report what it says. Do not retry the failing call.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MSAL token cache, persisted to disk.
|
|
3
|
+
*
|
|
4
|
+
* MSAL keeps its cache in memory and hands us a serialized blob through the
|
|
5
|
+
* `ICachePlugin` hooks; without persisting it, every pi session would start
|
|
6
|
+
* with a fresh sign-in. One file per account+tenant under
|
|
7
|
+
* `~/.pi/agent/pi-teams-tokens/`, mode 0600 — the blob contains refresh
|
|
8
|
+
* tokens.
|
|
9
|
+
*
|
|
10
|
+
* Writes go through a private temp file and an atomic rename: a truncated
|
|
11
|
+
* cache would silently sign the user out.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
chmodSync,
|
|
20
|
+
statSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
} from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
import type { ICachePlugin, TokenCacheContext } from "@azure/msal-node";
|
|
27
|
+
import { getAgentDir } from "../config/index.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Paths
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export function getCacheDir(): string {
|
|
34
|
+
return join(getAgentDir(), "pi-teams-tokens");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Filesystem-safe cache key for an account+tenant pair. */
|
|
38
|
+
export function cacheKey(account: string, tenantId: string): string {
|
|
39
|
+
const clean = (value: string) => value.toLowerCase().replace(/[^a-z0-9._-]/g, "_");
|
|
40
|
+
return `${clean(account)}__${clean(tenantId)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getCachePath(account: string, tenantId: string): string {
|
|
44
|
+
return join(getCacheDir(), `${cacheKey(account, tenantId)}.json`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// I/O
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
function readBlob(path: string): string | undefined {
|
|
52
|
+
if (!existsSync(path)) return undefined;
|
|
53
|
+
try {
|
|
54
|
+
// Older files, or a cache copied between machines, may be too permissive
|
|
55
|
+
// for something holding refresh tokens.
|
|
56
|
+
try {
|
|
57
|
+
const mode = statSync(path).mode & 0o777;
|
|
58
|
+
if (mode !== 0o600) chmodSync(path, 0o600);
|
|
59
|
+
} catch {
|
|
60
|
+
/* ignore */
|
|
61
|
+
}
|
|
62
|
+
return readFileSync(path, "utf-8");
|
|
63
|
+
} catch {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function writeBlob(path: string, blob: string): void {
|
|
69
|
+
const dir = getCacheDir();
|
|
70
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
71
|
+
|
|
72
|
+
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
73
|
+
try {
|
|
74
|
+
writeFileSync(tmpPath, blob, { encoding: "utf-8", mode: 0o600 });
|
|
75
|
+
chmodSync(tmpPath, 0o600);
|
|
76
|
+
renameSync(tmpPath, path);
|
|
77
|
+
} catch {
|
|
78
|
+
try {
|
|
79
|
+
unlinkSync(tmpPath);
|
|
80
|
+
} catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
// A cache that cannot be written is a degraded session, not a failed one:
|
|
84
|
+
// the tokens still work until the process exits.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Plugin
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/** Build the cache plugin for one account+tenant. */
|
|
93
|
+
export function createCachePlugin(account: string, tenantId: string): ICachePlugin {
|
|
94
|
+
const path = getCachePath(account, tenantId);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
async beforeCacheAccess(context: TokenCacheContext): Promise<void> {
|
|
98
|
+
const blob = readBlob(path);
|
|
99
|
+
if (blob) context.tokenCache.deserialize(blob);
|
|
100
|
+
},
|
|
101
|
+
async afterCacheAccess(context: TokenCacheContext): Promise<void> {
|
|
102
|
+
if (context.cacheHasChanged) writeBlob(path, context.tokenCache.serialize());
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Inspection
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** What the status surfaces need to know, readable without MSAL or a network call. */
|
|
112
|
+
export interface CacheSummary {
|
|
113
|
+
/** A cache file exists and holds an account */
|
|
114
|
+
present: boolean;
|
|
115
|
+
/** Signed-in identity, when the cache holds one */
|
|
116
|
+
username?: string;
|
|
117
|
+
name?: string;
|
|
118
|
+
/** Epoch milliseconds at which the newest access token expires */
|
|
119
|
+
expiresAt?: number;
|
|
120
|
+
/** Whether that access token is still valid */
|
|
121
|
+
fresh: boolean;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read the identity and expiry out of a cache file.
|
|
128
|
+
*
|
|
129
|
+
* The blob is MSAL's own schema, so this reads it defensively: a shape change
|
|
130
|
+
* upstream must degrade to "not signed in" in the status line, never throw in
|
|
131
|
+
* the middle of rendering the TUI.
|
|
132
|
+
*/
|
|
133
|
+
export function readCacheSummary(account: string, tenantId: string): CacheSummary {
|
|
134
|
+
const blob = readBlob(getCachePath(account, tenantId));
|
|
135
|
+
if (!blob) return { present: false, fresh: false };
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(blob) as Record<string, any>;
|
|
139
|
+
|
|
140
|
+
const accounts = Object.values(parsed.Account ?? {}) as any[];
|
|
141
|
+
const first = accounts[0];
|
|
142
|
+
|
|
143
|
+
const tokens = Object.values(parsed.AccessToken ?? {}) as any[];
|
|
144
|
+
// expires_on is seconds-since-epoch, as a string, in MSAL's schema.
|
|
145
|
+
const expiries = tokens
|
|
146
|
+
.map((token) => Number(token?.expires_on))
|
|
147
|
+
.filter((value) => Number.isFinite(value) && value > 0);
|
|
148
|
+
const expiresAt = expiries.length > 0 ? Math.max(...expiries) * 1000 : undefined;
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
present: accounts.length > 0 || tokens.length > 0,
|
|
152
|
+
username: first?.username,
|
|
153
|
+
name: first?.name,
|
|
154
|
+
expiresAt,
|
|
155
|
+
fresh: expiresAt !== undefined && expiresAt - 5 * 60 * 1000 > Date.now(),
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
return { present: false, fresh: false };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Delete one cached session. Returns true when a file was removed. */
|
|
163
|
+
export function removeCache(account: string, tenantId: string): boolean {
|
|
164
|
+
const path = getCachePath(account, tenantId);
|
|
165
|
+
if (!existsSync(path)) return false;
|
|
166
|
+
try {
|
|
167
|
+
unlinkSync(path);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Delete every cached session. Returns how many were removed. */
|
|
175
|
+
export function removeAllCaches(): number {
|
|
176
|
+
const dir = getCacheDir();
|
|
177
|
+
if (!existsSync(dir)) return 0;
|
|
178
|
+
|
|
179
|
+
let removed = 0;
|
|
180
|
+
for (const entry of readdirSync(dir)) {
|
|
181
|
+
if (!entry.endsWith(".json")) continue;
|
|
182
|
+
try {
|
|
183
|
+
unlinkSync(join(dir, entry));
|
|
184
|
+
removed += 1;
|
|
185
|
+
} catch {
|
|
186
|
+
/* skip what we cannot remove */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return removed;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every cached session on disk, for `teams_status` and the doctor. */
|
|
193
|
+
export function listCachedSessions(): Array<{ key: string; summary: CacheSummary }> {
|
|
194
|
+
const dir = getCacheDir();
|
|
195
|
+
if (!existsSync(dir)) return [];
|
|
196
|
+
|
|
197
|
+
const sessions: Array<{ key: string; summary: CacheSummary }> = [];
|
|
198
|
+
for (const entry of readdirSync(dir)) {
|
|
199
|
+
if (!entry.endsWith(".json")) continue;
|
|
200
|
+
const key = entry.replace(/\.json$/, "");
|
|
201
|
+
const blob = readBlob(join(dir, entry));
|
|
202
|
+
if (!blob) continue;
|
|
203
|
+
try {
|
|
204
|
+
const parsed = JSON.parse(blob) as Record<string, any>;
|
|
205
|
+
const first = (Object.values(parsed.Account ?? {}) as any[])[0];
|
|
206
|
+
sessions.push({
|
|
207
|
+
key,
|
|
208
|
+
summary: { present: true, username: first?.username, name: first?.name, fresh: false },
|
|
209
|
+
});
|
|
210
|
+
} catch {
|
|
211
|
+
/* ignore an unreadable cache file */
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return sessions;
|
|
215
|
+
}
|