@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/src/auth/device-code.ts
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OAuth 2.0 device authorization grant — the flow that makes pi *you*.
|
|
3
|
-
*
|
|
4
|
-
* The user opens a URL, types a short code, and signs in with their own
|
|
5
|
-
* credentials and MFA. Everything pi does afterwards is attributed to that
|
|
6
|
-
* person in Teams: messages come from them, and pi sees exactly what they see —
|
|
7
|
-
* no more.
|
|
8
|
-
*
|
|
9
|
-
* Implemented against the token endpoint directly rather than through MSAL:
|
|
10
|
-
* the extension ships with no build step, and one fetch-based module is easier
|
|
11
|
-
* to audit than a dependency tree.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import type { TeamsConnection } from "../config/index.ts";
|
|
15
|
-
import { decodeIdToken } from "./jwt.ts";
|
|
16
|
-
import type { CachedToken } from "./token-store.ts";
|
|
17
|
-
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Types
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
/** What the user needs in order to complete the sign-in. */
|
|
23
|
-
export interface DeviceCodeChallenge {
|
|
24
|
-
deviceCode: string;
|
|
25
|
-
userCode: string;
|
|
26
|
-
verificationUri: string;
|
|
27
|
-
/** Message Microsoft suggests showing to the user */
|
|
28
|
-
message: string;
|
|
29
|
-
/** Epoch ms after which the code is dead */
|
|
30
|
-
expiresAt: number;
|
|
31
|
-
/** Seconds between polls, as requested by the server */
|
|
32
|
-
intervalSeconds: number;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface DeviceCodeResponse {
|
|
36
|
-
device_code: string;
|
|
37
|
-
user_code: string;
|
|
38
|
-
verification_uri: string;
|
|
39
|
-
expires_in: number;
|
|
40
|
-
interval: number;
|
|
41
|
-
message: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
interface TokenResponse {
|
|
45
|
-
access_token: string;
|
|
46
|
-
refresh_token?: string;
|
|
47
|
-
id_token?: string;
|
|
48
|
-
expires_in: number;
|
|
49
|
-
scope?: string;
|
|
50
|
-
token_type: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface ErrorResponse {
|
|
54
|
-
error: string;
|
|
55
|
-
error_description?: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** A token endpoint error that callers may want to branch on. */
|
|
59
|
-
export class AuthError extends Error {
|
|
60
|
-
readonly code: string;
|
|
61
|
-
constructor(code: string, message: string) {
|
|
62
|
-
super(message);
|
|
63
|
-
this.name = "AuthError";
|
|
64
|
-
this.code = code;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Endpoints
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
function deviceCodeUrl(conn: TeamsConnection): string {
|
|
73
|
-
return `${conn.authorityHost}/${encodeURIComponent(conn.tenantId)}/oauth2/v2.0/devicecode`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function tokenUrl(authorityHost: string, tenantId: string): string {
|
|
77
|
-
return `${authorityHost}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
// Step 1 — request a code
|
|
82
|
-
// ---------------------------------------------------------------------------
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Ask Microsoft for a device code.
|
|
86
|
-
*
|
|
87
|
-
* @throws {AuthError} when the app registration rejects the request — most
|
|
88
|
-
* often because "Allow public client flows" is still off.
|
|
89
|
-
*/
|
|
90
|
-
export async function requestDeviceCode(
|
|
91
|
-
conn: TeamsConnection,
|
|
92
|
-
signal?: AbortSignal,
|
|
93
|
-
): Promise<DeviceCodeChallenge> {
|
|
94
|
-
const body = new URLSearchParams({
|
|
95
|
-
client_id: conn.clientId,
|
|
96
|
-
scope: conn.scopes.join(" "),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
const response = await fetch(deviceCodeUrl(conn), {
|
|
100
|
-
method: "POST",
|
|
101
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
102
|
-
body: body.toString(),
|
|
103
|
-
signal,
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const json = (await response.json()) as DeviceCodeResponse & Partial<ErrorResponse>;
|
|
107
|
-
|
|
108
|
-
if (!response.ok || json.error) {
|
|
109
|
-
throw new AuthError(
|
|
110
|
-
json.error ?? `http_${response.status}`,
|
|
111
|
-
json.error_description ?? `Device code request failed with HTTP ${response.status}`,
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return {
|
|
116
|
-
deviceCode: json.device_code,
|
|
117
|
-
userCode: json.user_code,
|
|
118
|
-
verificationUri: json.verification_uri,
|
|
119
|
-
message: json.message,
|
|
120
|
-
expiresAt: Date.now() + json.expires_in * 1000,
|
|
121
|
-
intervalSeconds: json.interval || 5,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
// Step 2 — poll until the user is done
|
|
127
|
-
// ---------------------------------------------------------------------------
|
|
128
|
-
|
|
129
|
-
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
130
|
-
return new Promise((resolve, reject) => {
|
|
131
|
-
const timer = setTimeout(resolve, ms);
|
|
132
|
-
signal?.addEventListener(
|
|
133
|
-
"abort",
|
|
134
|
-
() => {
|
|
135
|
-
clearTimeout(timer);
|
|
136
|
-
reject(new AuthError("aborted", "Sign-in cancelled."));
|
|
137
|
-
},
|
|
138
|
-
{ once: true },
|
|
139
|
-
);
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Poll the token endpoint until the user finishes signing in.
|
|
145
|
-
*
|
|
146
|
-
* `onPending` is called on every poll so a tool can keep the UI alive during
|
|
147
|
-
* what may be a minute or two of waiting.
|
|
148
|
-
*/
|
|
149
|
-
export async function pollForToken(
|
|
150
|
-
conn: TeamsConnection,
|
|
151
|
-
challenge: DeviceCodeChallenge,
|
|
152
|
-
options: { signal?: AbortSignal; onPending?: (secondsLeft: number) => void } = {},
|
|
153
|
-
): Promise<CachedToken> {
|
|
154
|
-
const { signal, onPending } = options;
|
|
155
|
-
let intervalMs = challenge.intervalSeconds * 1000;
|
|
156
|
-
|
|
157
|
-
for (;;) {
|
|
158
|
-
if (Date.now() > challenge.expiresAt) {
|
|
159
|
-
throw new AuthError("expired_token", "The device code expired before sign-in completed.");
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
await sleep(intervalMs, signal);
|
|
163
|
-
onPending?.(Math.max(0, Math.round((challenge.expiresAt - Date.now()) / 1000)));
|
|
164
|
-
|
|
165
|
-
const body = new URLSearchParams({
|
|
166
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
167
|
-
client_id: conn.clientId,
|
|
168
|
-
device_code: challenge.deviceCode,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
|
|
172
|
-
method: "POST",
|
|
173
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
174
|
-
body: body.toString(),
|
|
175
|
-
signal,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const json = (await response.json()) as TokenResponse & Partial<ErrorResponse>;
|
|
179
|
-
|
|
180
|
-
if (response.ok && json.access_token) {
|
|
181
|
-
return toCachedToken(json, "device-code");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
switch (json.error) {
|
|
185
|
-
case "authorization_pending":
|
|
186
|
-
continue;
|
|
187
|
-
case "slow_down":
|
|
188
|
-
// The server asks for more breathing room; honour it permanently.
|
|
189
|
-
intervalMs += 5000;
|
|
190
|
-
continue;
|
|
191
|
-
case "authorization_declined":
|
|
192
|
-
throw new AuthError("authorization_declined", "Sign-in was declined in the browser.");
|
|
193
|
-
case "expired_token":
|
|
194
|
-
throw new AuthError("expired_token", "The device code expired before sign-in completed.");
|
|
195
|
-
case "bad_verification_code":
|
|
196
|
-
throw new AuthError("bad_verification_code", "The device code was rejected. Start over.");
|
|
197
|
-
default:
|
|
198
|
-
throw new AuthError(
|
|
199
|
-
json.error ?? `http_${response.status}`,
|
|
200
|
-
json.error_description ?? `Token request failed with HTTP ${response.status}`,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// ---------------------------------------------------------------------------
|
|
207
|
-
// Step 3 — refresh
|
|
208
|
-
// ---------------------------------------------------------------------------
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Exchange a refresh token for a fresh access token.
|
|
212
|
-
*
|
|
213
|
-
* @throws {AuthError} with code `invalid_grant` when the refresh token is dead
|
|
214
|
-
* (password change, revoked session, conditional access) — the caller should
|
|
215
|
-
* then ask the user to run `teams_login` again.
|
|
216
|
-
*/
|
|
217
|
-
export async function refreshAccessToken(
|
|
218
|
-
conn: TeamsConnection,
|
|
219
|
-
refreshToken: string,
|
|
220
|
-
signal?: AbortSignal,
|
|
221
|
-
): Promise<CachedToken> {
|
|
222
|
-
const body = new URLSearchParams({
|
|
223
|
-
grant_type: "refresh_token",
|
|
224
|
-
client_id: conn.clientId,
|
|
225
|
-
refresh_token: refreshToken,
|
|
226
|
-
scope: conn.scopes.join(" "),
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
|
|
230
|
-
method: "POST",
|
|
231
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
232
|
-
body: body.toString(),
|
|
233
|
-
signal,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const json = (await response.json()) as TokenResponse & Partial<ErrorResponse>;
|
|
237
|
-
|
|
238
|
-
if (!response.ok || !json.access_token) {
|
|
239
|
-
throw new AuthError(
|
|
240
|
-
json.error ?? `http_${response.status}`,
|
|
241
|
-
json.error_description ?? `Token refresh failed with HTTP ${response.status}`,
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Entra ID rotates refresh tokens; keep the old one only if none came back.
|
|
246
|
-
return { ...toCachedToken(json, "device-code"), refreshToken: json.refresh_token ?? refreshToken };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
// Shaping
|
|
251
|
-
// ---------------------------------------------------------------------------
|
|
252
|
-
|
|
253
|
-
function toCachedToken(
|
|
254
|
-
json: TokenResponse,
|
|
255
|
-
authMode: "device-code" | "client-credentials",
|
|
256
|
-
): CachedToken {
|
|
257
|
-
const claims = json.id_token ? decodeIdToken(json.id_token) : undefined;
|
|
258
|
-
return {
|
|
259
|
-
accessToken: json.access_token,
|
|
260
|
-
refreshToken: json.refresh_token,
|
|
261
|
-
// Shave 60s off the stated lifetime so clock skew never hands Graph a
|
|
262
|
-
// token that expired a second ago.
|
|
263
|
-
expiresAt: Date.now() + Math.max(0, json.expires_in - 60) * 1000,
|
|
264
|
-
scopes: json.scope ? json.scope.split(" ") : [],
|
|
265
|
-
authMode,
|
|
266
|
-
user: claims
|
|
267
|
-
? {
|
|
268
|
-
id: claims.oid,
|
|
269
|
-
displayName: claims.name,
|
|
270
|
-
upn: claims.preferred_username ?? claims.upn,
|
|
271
|
-
tenantId: claims.tid,
|
|
272
|
-
}
|
|
273
|
-
: undefined,
|
|
274
|
-
updatedAt: new Date().toISOString(),
|
|
275
|
-
};
|
|
276
|
-
}
|
package/src/auth/token-store.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Token cache — `~/.pi/agent/pi-teams-tokens.json`, mode 0600.
|
|
3
|
-
*
|
|
4
|
-
* One entry per account+tenant. Holds the access token, its expiry, the
|
|
5
|
-
* refresh token, the granted scopes, and who the token belongs to, so
|
|
6
|
-
* `teams_status` can say *which user* pi is currently acting as without a
|
|
7
|
-
* network round-trip.
|
|
8
|
-
*
|
|
9
|
-
* Writes go through a private temp file and an atomic rename: the file holds
|
|
10
|
-
* long-lived refresh tokens, and a truncated cache would silently sign the
|
|
11
|
-
* user out.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
existsSync,
|
|
16
|
-
readFileSync,
|
|
17
|
-
writeFileSync,
|
|
18
|
-
mkdirSync,
|
|
19
|
-
chmodSync,
|
|
20
|
-
statSync,
|
|
21
|
-
renameSync,
|
|
22
|
-
unlinkSync,
|
|
23
|
-
} from "node:fs";
|
|
24
|
-
import { dirname, join } from "node:path";
|
|
25
|
-
import { getAgentDir } from "../config/index.ts";
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Types
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
export interface CachedToken {
|
|
32
|
-
/** Bearer token */
|
|
33
|
-
accessToken: string;
|
|
34
|
-
/** Epoch milliseconds when the access token expires */
|
|
35
|
-
expiresAt: number;
|
|
36
|
-
/** Refresh token — absent for client-credentials */
|
|
37
|
-
refreshToken?: string;
|
|
38
|
-
/** Scopes the token was actually granted */
|
|
39
|
-
scopes: string[];
|
|
40
|
-
/** Which flow produced this token */
|
|
41
|
-
authMode: "device-code" | "client-credentials";
|
|
42
|
-
/** Signed-in identity (delegated tokens only) */
|
|
43
|
-
user?: {
|
|
44
|
-
id?: string;
|
|
45
|
-
displayName?: string;
|
|
46
|
-
upn?: string;
|
|
47
|
-
tenantId?: string;
|
|
48
|
-
};
|
|
49
|
-
/** When the entry was last written */
|
|
50
|
-
updatedAt: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface TokenFile {
|
|
54
|
-
version: 1;
|
|
55
|
-
tokens: Record<string, CachedToken>;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
// Paths & keys
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
export function getTokenPath(): string {
|
|
63
|
-
return join(getAgentDir(), "pi-teams-tokens.json");
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Cache key for an account+tenant pair. */
|
|
67
|
-
export function tokenKey(account: string, tenantId: string): string {
|
|
68
|
-
return `${account.toLowerCase()}::${tenantId.toLowerCase()}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// ---------------------------------------------------------------------------
|
|
72
|
-
// I/O
|
|
73
|
-
// ---------------------------------------------------------------------------
|
|
74
|
-
|
|
75
|
-
function readFile(): TokenFile {
|
|
76
|
-
const path = getTokenPath();
|
|
77
|
-
if (!existsSync(path)) return { version: 1, tokens: {} };
|
|
78
|
-
try {
|
|
79
|
-
try {
|
|
80
|
-
const mode = statSync(path).mode & 0o777;
|
|
81
|
-
if (mode !== 0o600) chmodSync(path, 0o600);
|
|
82
|
-
} catch {
|
|
83
|
-
/* ignore */
|
|
84
|
-
}
|
|
85
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8")) as TokenFile;
|
|
86
|
-
if (!parsed || typeof parsed !== "object" || !parsed.tokens) return { version: 1, tokens: {} };
|
|
87
|
-
return parsed;
|
|
88
|
-
} catch {
|
|
89
|
-
return { version: 1, tokens: {} };
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function writeFile(data: TokenFile): void {
|
|
94
|
-
const path = getTokenPath();
|
|
95
|
-
const dir = dirname(path);
|
|
96
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
97
|
-
|
|
98
|
-
const tmpPath = `${path}.${process.pid}.tmp`;
|
|
99
|
-
try {
|
|
100
|
-
writeFileSync(tmpPath, `${JSON.stringify(data, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 });
|
|
101
|
-
chmodSync(tmpPath, 0o600);
|
|
102
|
-
renameSync(tmpPath, path);
|
|
103
|
-
} catch (err) {
|
|
104
|
-
try {
|
|
105
|
-
unlinkSync(tmpPath);
|
|
106
|
-
} catch {
|
|
107
|
-
/* ignore */
|
|
108
|
-
}
|
|
109
|
-
throw err;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// Access
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
|
|
117
|
-
export function getToken(account: string, tenantId: string): CachedToken | undefined {
|
|
118
|
-
return readFile().tokens[tokenKey(account, tenantId)];
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function saveToken(account: string, tenantId: string, token: CachedToken): void {
|
|
122
|
-
const file = readFile();
|
|
123
|
-
file.tokens[tokenKey(account, tenantId)] = { ...token, updatedAt: new Date().toISOString() };
|
|
124
|
-
writeFile(file);
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function deleteToken(account: string, tenantId: string): boolean {
|
|
128
|
-
const file = readFile();
|
|
129
|
-
const key = tokenKey(account, tenantId);
|
|
130
|
-
if (!file.tokens[key]) return false;
|
|
131
|
-
delete file.tokens[key];
|
|
132
|
-
writeFile(file);
|
|
133
|
-
return true;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** Drop every cached token — used by `teams_logout` with `all: true`. */
|
|
137
|
-
export function clearAllTokens(): number {
|
|
138
|
-
const file = readFile();
|
|
139
|
-
const count = Object.keys(file.tokens).length;
|
|
140
|
-
writeFile({ version: 1, tokens: {} });
|
|
141
|
-
return count;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function listTokens(): Array<{ key: string; token: CachedToken }> {
|
|
145
|
-
const file = readFile();
|
|
146
|
-
return Object.entries(file.tokens).map(([key, token]) => ({ key, token }));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Is the token still usable?
|
|
151
|
-
* A 5-minute skew keeps a long Graph call from failing mid-flight.
|
|
152
|
-
*/
|
|
153
|
-
export function isFresh(token: CachedToken | undefined, skewMs = 5 * 60 * 1000): boolean {
|
|
154
|
-
if (!token?.accessToken) return false;
|
|
155
|
-
return token.expiresAt - skewMs > Date.now();
|
|
156
|
-
}
|