@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/src/auth/index.ts CHANGED
@@ -1,19 +1,79 @@
1
1
  /**
2
2
  * Auth resolver — hands the Graph layer a valid bearer token.
3
3
  *
4
- * Order of preference for a delegated connection:
5
- * 1. a cached access token that is still fresh
6
- * 2. a refresh of the cached refresh token
7
- * 3. nothing — the caller must run `teams_login`
4
+ * Built on MSAL, so the flows that matter are one call each:
8
5
  *
9
- * App-only connections skip the cache-refresh dance; a new token is cheap and
10
- * there is no user to interrupt.
6
+ * - **interactive**: `acquireTokenInteractive` opens the system browser at
7
+ * the Microsoft sign-in page, listens on a loopback port for the redirect,
8
+ * and does PKCE. The default whenever a browser is actually reachable.
9
+ * - **device code**: `acquireTokenByDeviceCode` for SSH, containers and
10
+ * anything headless — the user types a short code on another device.
11
+ * - **client credentials**: app-only, no user. Cannot post as a person; the
12
+ * safety layer refuses those tools rather than letting Graph reject them.
13
+ *
14
+ * Silent renewal is MSAL's `acquireTokenSilent`, which uses the cached refresh
15
+ * token and rotates it. The cache lives on disk (see ./cache-plugin.ts), so a
16
+ * sign-in survives across pi sessions.
11
17
  */
12
18
 
19
+ import {
20
+ InteractionRequiredAuthError,
21
+ type AccountInfo,
22
+ type AuthenticationResult,
23
+ } from "@azure/msal-node";
13
24
  import type { TeamsConnection } from "../config/index.ts";
14
- import { AuthError, refreshAccessToken } from "./device-code.ts";
15
- import { acquireAppToken } from "./client-credentials.ts";
16
- import { getToken, saveToken, isFresh, type CachedToken } from "./token-store.ts";
25
+ import { removeAllCaches, removeCache, readCacheSummary } from "./cache-plugin.ts";
26
+ import {
27
+ ERROR_TEMPLATE,
28
+ SUCCESS_TEMPLATE,
29
+ browserUnavailableReason,
30
+ canOpenBrowser,
31
+ getConfidentialApp,
32
+ getPublicApp,
33
+ openBrowser,
34
+ requestScopes,
35
+ resetApps,
36
+ } from "./msal.ts";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Types
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** A usable bearer token plus who it belongs to. */
43
+ export interface TokenInfo {
44
+ accessToken: string;
45
+ expiresOn?: Date;
46
+ scopes: string[];
47
+ account?: SignedInAccount;
48
+ }
49
+
50
+ export interface SignedInAccount {
51
+ homeAccountId: string;
52
+ username: string;
53
+ name?: string;
54
+ tenantId: string;
55
+ }
56
+
57
+ /** How a sign-in was performed, or should be. */
58
+ export type SignInMode = "interactive" | "device-code";
59
+
60
+ /** What the user must do to complete a device code sign-in. */
61
+ export interface DeviceCodeInfo {
62
+ userCode: string;
63
+ verificationUri: string;
64
+ message: string;
65
+ expiresIn: number;
66
+ }
67
+
68
+ /** A failure from the identity provider. */
69
+ export class AuthError extends Error {
70
+ readonly code: string;
71
+ constructor(code: string, message: string) {
72
+ super(message);
73
+ this.name = "AuthError";
74
+ this.code = code;
75
+ }
76
+ }
17
77
 
18
78
  /** Raised when there is no usable token and only the user can fix it. */
19
79
  export class NotSignedInError extends Error {
@@ -30,76 +90,263 @@ export class NotSignedInError extends Error {
30
90
  }
31
91
  }
32
92
 
33
- /** In-process cache so a burst of Graph calls does not re-read the token file. */
34
- const memoryCache = new Map<string, CachedToken>();
93
+ // ---------------------------------------------------------------------------
94
+ // Shaping
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function toAccount(account: AccountInfo | null): SignedInAccount | undefined {
98
+ if (!account) return undefined;
99
+ return {
100
+ homeAccountId: account.homeAccountId,
101
+ username: account.username,
102
+ name: account.name,
103
+ tenantId: account.tenantId,
104
+ };
105
+ }
106
+
107
+ function toTokenInfo(result: AuthenticationResult): TokenInfo {
108
+ return {
109
+ accessToken: result.accessToken,
110
+ expiresOn: result.expiresOn ?? undefined,
111
+ scopes: result.scopes ?? [],
112
+ account: toAccount(result.account),
113
+ };
114
+ }
115
+
116
+ function wrapError(err: unknown): never {
117
+ if (err instanceof AuthError || err instanceof NotSignedInError) throw err;
118
+ const error = err as { errorCode?: string; errorMessage?: string; message?: string };
119
+ throw new AuthError(
120
+ error.errorCode ?? "auth_failed",
121
+ error.errorMessage ?? error.message ?? String(err),
122
+ );
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Account lookup
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /** The cached account for a connection, if the user has signed in before. */
130
+ export async function cachedAccount(conn: TeamsConnection): Promise<SignedInAccount | undefined> {
131
+ const accounts = await getPublicApp(conn).getTokenCache().getAllAccounts();
132
+ if (accounts.length === 0) return undefined;
133
+
134
+ // One cache file per account+tenant, so there is normally exactly one. When
135
+ // a user has signed in as more than one identity, prefer this tenant's.
136
+ const match =
137
+ accounts.find((account) => account.tenantId?.toLowerCase() === conn.tenantId.toLowerCase()) ??
138
+ accounts[0];
139
+ return toAccount(match ?? null);
140
+ }
35
141
 
36
- function memKey(conn: TeamsConnection): string {
37
- return `${conn.account.toLowerCase()}::${conn.tenantId.toLowerCase()}`;
142
+ /** Cheap, synchronous "has this connection ever signed in" — for status output. */
143
+ export function hasCachedSession(conn: TeamsConnection): boolean {
144
+ return readCacheSummary(conn.account, conn.tenantId).present;
38
145
  }
39
146
 
147
+ // ---------------------------------------------------------------------------
148
+ // Silent acquisition
149
+ // ---------------------------------------------------------------------------
150
+
40
151
  /**
41
- * Get a bearer token for this connection, refreshing it when needed.
152
+ * Get a token for this connection, renewing it silently when needed.
42
153
  *
43
- * @throws {NotSignedInError} when a delegated sign-in is required
154
+ * @throws {NotSignedInError} when only an interactive sign-in can help
44
155
  * @throws {AuthError} when the identity provider refuses
45
156
  */
46
157
  export async function getAccessToken(
47
158
  conn: TeamsConnection,
48
- signal?: AbortSignal,
49
- ): Promise<CachedToken> {
50
- const key = memKey(conn);
159
+ _signal?: AbortSignal,
160
+ ): Promise<TokenInfo> {
161
+ if (conn.authMode === "client-credentials") {
162
+ if (!conn.clientSecret) {
163
+ throw new AuthError(
164
+ "missing_client_secret",
165
+ `Account "${conn.account}" uses client-credentials but has no clientSecret.`,
166
+ );
167
+ }
168
+ try {
169
+ const result = await getConfidentialApp(conn).acquireTokenByClientCredential({
170
+ scopes: ["https://graph.microsoft.com/.default"],
171
+ });
172
+ if (!result) throw new AuthError("no_token", "Microsoft returned no app-only token.");
173
+ return toTokenInfo(result);
174
+ } catch (err) {
175
+ wrapError(err);
176
+ }
177
+ }
51
178
 
52
- const inMemory = memoryCache.get(key);
53
- if (isFresh(inMemory)) return inMemory!;
179
+ const app = getPublicApp(conn);
180
+ const accounts = await app.getTokenCache().getAllAccounts();
181
+ const account =
182
+ accounts.find((entry) => entry.tenantId?.toLowerCase() === conn.tenantId.toLowerCase()) ??
183
+ accounts[0];
54
184
 
55
- if (conn.authMode === "client-credentials") {
56
- const token = await acquireAppToken(conn, signal);
57
- memoryCache.set(key, token);
58
- saveToken(conn.account, conn.tenantId, token);
59
- return token;
185
+ if (!account) throw new NotSignedInError(conn.account, conn.tenant);
186
+
187
+ try {
188
+ const result = await app.acquireTokenSilent({
189
+ account,
190
+ scopes: requestScopes(conn),
191
+ });
192
+ if (!result) throw new NotSignedInError(conn.account, conn.tenant);
193
+ return toTokenInfo(result);
194
+ } catch (err) {
195
+ // Password change, revoked session, conditional access, a new scope the
196
+ // cached refresh token was never granted — all need the user back.
197
+ if (err instanceof InteractionRequiredAuthError) {
198
+ throw new NotSignedInError(
199
+ conn.account,
200
+ conn.tenant,
201
+ "the saved sign-in expired or needs to be renewed",
202
+ );
203
+ }
204
+ wrapError(err);
60
205
  }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Sign-in
210
+ // ---------------------------------------------------------------------------
211
+
212
+ export interface SignInOptions {
213
+ /** Force a flow instead of letting the environment decide */
214
+ mode?: SignInMode;
215
+ /** Called once with the code and URL when the device code flow is used */
216
+ onDeviceCode?: (info: DeviceCodeInfo) => void;
217
+ /** Called just before the browser opens */
218
+ onBrowser?: (url: string) => void;
219
+ /** Fixed loopback port, for tenants that require an exact redirect URI */
220
+ loopbackPort?: number;
221
+ /** How long to wait for the user, in milliseconds (default 5 minutes) */
222
+ timeoutMs?: number;
223
+ signal?: AbortSignal;
224
+ }
225
+
226
+ export interface SignInResult extends TokenInfo {
227
+ /** Which flow actually ran */
228
+ mode: SignInMode;
229
+ }
61
230
 
62
- const cached = getToken(conn.account, conn.tenantId);
231
+ /** Pick a flow for this environment. */
232
+ export function defaultSignInMode(): SignInMode {
233
+ return canOpenBrowser() ? "interactive" : "device-code";
234
+ }
63
235
 
64
- if (isFresh(cached)) {
65
- memoryCache.set(key, cached!);
66
- return cached!;
236
+ export { browserUnavailableReason, canOpenBrowser };
237
+
238
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
239
+ return new Promise<T>((resolve, reject) => {
240
+ const timer = setTimeout(() => {
241
+ reject(new AuthError("timeout", `${label} timed out after ${Math.round(ms / 1000)}s.`));
242
+ }, ms);
243
+ promise.then(
244
+ (value) => {
245
+ clearTimeout(timer);
246
+ resolve(value);
247
+ },
248
+ (err) => {
249
+ clearTimeout(timer);
250
+ reject(err);
251
+ },
252
+ );
253
+ });
254
+ }
255
+
256
+ /**
257
+ * Sign in as the user.
258
+ *
259
+ * Interactive by default: the browser opens, the user signs in with their own
260
+ * credentials and MFA, and MSAL catches the redirect on a loopback port. On a
261
+ * machine where that cannot work, the device code flow takes over.
262
+ */
263
+ export async function signIn(
264
+ conn: TeamsConnection,
265
+ options: SignInOptions = {},
266
+ ): Promise<SignInResult> {
267
+ if (conn.authMode === "client-credentials") {
268
+ const token = await getAccessToken(conn, options.signal);
269
+ return { ...token, mode: "device-code" };
67
270
  }
68
271
 
69
- if (cached?.refreshToken) {
272
+ const mode = options.mode ?? (conn.authMode === "device-code" ? "device-code" : defaultSignInMode());
273
+ const app = getPublicApp(conn);
274
+ const scopes = requestScopes(conn);
275
+ const timeoutMs = options.timeoutMs ?? 5 * 60 * 1000;
276
+
277
+ if (mode === "interactive") {
70
278
  try {
71
- const refreshed = await refreshAccessToken(conn, cached.refreshToken, signal);
72
- // Keep the identity we already know; a refresh response carries no id_token
73
- // unless openid was requested again.
74
- const merged: CachedToken = { ...refreshed, user: refreshed.user ?? cached.user };
75
- memoryCache.set(key, merged);
76
- saveToken(conn.account, conn.tenantId, merged);
77
- return merged;
279
+ const result = await withTimeout(
280
+ app.acquireTokenInteractive({
281
+ scopes,
282
+ openBrowser: async (url: string) => {
283
+ options.onBrowser?.(url);
284
+ await openBrowser(url);
285
+ },
286
+ successTemplate: SUCCESS_TEMPLATE,
287
+ errorTemplate: ERROR_TEMPLATE,
288
+ ...(options.loopbackPort ? { preferredPort: options.loopbackPort } : {}),
289
+ }),
290
+ timeoutMs,
291
+ "Browser sign-in",
292
+ );
293
+ if (!result) throw new AuthError("no_token", "Microsoft returned no token.");
294
+ return { ...toTokenInfo(result), mode: "interactive" };
78
295
  } catch (err) {
79
- if (err instanceof AuthError && err.code === "invalid_grant") {
80
- throw new NotSignedInError(conn.account, conn.tenant, "the saved sign-in expired or was revoked");
81
- }
82
- throw err;
296
+ wrapError(err);
83
297
  }
84
298
  }
85
299
 
86
- throw new NotSignedInError(conn.account, conn.tenant);
300
+ try {
301
+ const result = await withTimeout(
302
+ app.acquireTokenByDeviceCode({
303
+ scopes,
304
+ deviceCodeCallback: (response) => {
305
+ options.onDeviceCode?.({
306
+ userCode: response.userCode,
307
+ verificationUri: response.verificationUri,
308
+ message: response.message,
309
+ expiresIn: response.expiresIn,
310
+ });
311
+ },
312
+ }),
313
+ timeoutMs,
314
+ "Device code sign-in",
315
+ );
316
+ if (!result) throw new AuthError("no_token", "Microsoft returned no token.");
317
+ return { ...toTokenInfo(result), mode: "device-code" };
318
+ } catch (err) {
319
+ wrapError(err);
320
+ }
87
321
  }
88
322
 
89
- /** Store a freshly acquired token (used by `teams_login`). */
90
- export function storeToken(conn: TeamsConnection, token: CachedToken): void {
91
- memoryCache.set(memKey(conn), token);
92
- saveToken(conn.account, conn.tenantId, token);
93
- }
323
+ // ---------------------------------------------------------------------------
324
+ // Sign-out
325
+ // ---------------------------------------------------------------------------
326
+
327
+ /** Remove one cached session. Returns true when something was removed. */
328
+ export async function signOut(conn: TeamsConnection): Promise<boolean> {
329
+ // Ask MSAL to forget the account first so its in-memory copy goes too;
330
+ // removing only the file would leave this process still holding tokens.
331
+ try {
332
+ const cache = getPublicApp(conn).getTokenCache();
333
+ for (const account of await cache.getAllAccounts()) {
334
+ await cache.removeAccount(account);
335
+ }
336
+ } catch {
337
+ /* the file removal below is what actually matters */
338
+ }
94
339
 
95
- /** Forget the in-process token — the file cache is handled by token-store. */
96
- export function forgetToken(conn: TeamsConnection): void {
97
- memoryCache.delete(memKey(conn));
340
+ const removed = removeCache(conn.account, conn.tenantId);
341
+ resetApps();
342
+ return removed;
98
343
  }
99
344
 
100
- /** Drop every in-process token. */
101
- export function clearMemoryCache(): void {
102
- memoryCache.clear();
345
+ /** Remove every cached session. Returns how many were removed. */
346
+ export function signOutAll(): number {
347
+ const removed = removeAllCaches();
348
+ resetApps();
349
+ return removed;
103
350
  }
104
351
 
105
- export { AuthError };
352
+ export { resetApps };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * MSAL application factory and the browser plumbing around it.
3
+ *
4
+ * MSAL handles the parts of OAuth that are tedious to get right — PKCE, the
5
+ * loopback listener, refresh-token rotation, cross-tenant authorities — so
6
+ * this module's job is narrow: build one application per connection, decide
7
+ * whether a browser is actually reachable, and open it.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import {
12
+ ConfidentialClientApplication,
13
+ LogLevel,
14
+ PublicClientApplication,
15
+ type Configuration,
16
+ } from "@azure/msal-node";
17
+ import type { TeamsConnection } from "../config/index.ts";
18
+ import { browserUnavailableReason, canOpenBrowser } from "../utils/environment.ts";
19
+ import { createCachePlugin } from "./cache-plugin.ts";
20
+
21
+ export { browserUnavailableReason, canOpenBrowser };
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Reserved scopes
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /**
28
+ * MSAL adds these itself and rejects them in a request's scope list.
29
+ * They stay in the documented defaults because the doctor reports on them.
30
+ */
31
+ const RESERVED_SCOPES = new Set(["openid", "profile", "offline_access", "email"]);
32
+
33
+ export function requestScopes(conn: TeamsConnection): string[] {
34
+ return conn.scopes.filter((scope) => !RESERVED_SCOPES.has(scope.toLowerCase()));
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Application factory
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const publicApps = new Map<string, PublicClientApplication>();
42
+ const confidentialApps = new Map<string, ConfidentialClientApplication>();
43
+
44
+ function appKey(conn: TeamsConnection): string {
45
+ return `${conn.account.toLowerCase()}::${conn.tenantId.toLowerCase()}::${conn.clientId}`;
46
+ }
47
+
48
+ function baseConfig(conn: TeamsConnection): Configuration {
49
+ return {
50
+ auth: {
51
+ clientId: conn.clientId,
52
+ authority: `${conn.authorityHost}/${conn.tenantId}`,
53
+ },
54
+ cache: {
55
+ cachePlugin: createCachePlugin(conn.account, conn.tenantId),
56
+ },
57
+ system: {
58
+ loggerOptions: {
59
+ // MSAL logs to stdout by default, which would scribble over the TUI.
60
+ loggerCallback: () => {},
61
+ piiLoggingEnabled: false,
62
+ logLevel: LogLevel.Error,
63
+ },
64
+ },
65
+ };
66
+ }
67
+
68
+ /** The public client — used for every delegated (act-as-the-user) flow. */
69
+ export function getPublicApp(conn: TeamsConnection): PublicClientApplication {
70
+ const key = appKey(conn);
71
+ const existing = publicApps.get(key);
72
+ if (existing) return existing;
73
+
74
+ const app = new PublicClientApplication(baseConfig(conn));
75
+ publicApps.set(key, app);
76
+ return app;
77
+ }
78
+
79
+ /** The confidential client — app-only tokens. */
80
+ export function getConfidentialApp(conn: TeamsConnection): ConfidentialClientApplication {
81
+ const key = appKey(conn);
82
+ const existing = confidentialApps.get(key);
83
+ if (existing) return existing;
84
+
85
+ const config = baseConfig(conn);
86
+ const app = new ConfidentialClientApplication({
87
+ ...config,
88
+ auth: { ...config.auth, clientSecret: conn.clientSecret },
89
+ });
90
+ confidentialApps.set(key, app);
91
+ return app;
92
+ }
93
+
94
+ /** Drop the cached applications (after a sign-out or a config change). */
95
+ export function resetApps(): void {
96
+ publicApps.clear();
97
+ confidentialApps.clear();
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Browser
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Open a URL in the user's default browser, detached from this process. */
105
+ export function openBrowser(url: string): Promise<void> {
106
+ return new Promise((resolve, reject) => {
107
+ const [command, args] =
108
+ process.platform === "darwin"
109
+ ? ["open", [url]]
110
+ : process.platform === "win32"
111
+ ? // `start` is a shell builtin, and the empty string is the window
112
+ // title cmd.exe would otherwise take the URL for.
113
+ ["cmd.exe", ["/c", "start", "", url]]
114
+ : ["xdg-open", [url]];
115
+
116
+ try {
117
+ const child = spawn(command as string, args as string[], {
118
+ detached: true,
119
+ stdio: "ignore",
120
+ });
121
+ child.on("error", reject);
122
+ child.unref();
123
+ resolve();
124
+ } catch (err) {
125
+ reject(err instanceof Error ? err : new Error(String(err)));
126
+ }
127
+ });
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Browser response pages
132
+ // ---------------------------------------------------------------------------
133
+
134
+ function page(title: string, message: string, accent: string): string {
135
+ return `<!doctype html>
136
+ <html lang="en"><head><meta charset="utf-8">
137
+ <title>${title}</title>
138
+ <style>
139
+ :root { color-scheme: light dark; }
140
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
141
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
142
+ background: #fbfbfa; color: #1a1a18; }
143
+ @media (prefers-color-scheme: dark) { body { background: #1a1a18; color: #f5f4ef; } }
144
+ .card { max-width: 28rem; padding: 2.5rem; text-align: center; }
145
+ .mark { width: 3rem; height: 3rem; margin: 0 auto 1.25rem; border-radius: 50%;
146
+ background: ${accent}; display: grid; place-items: center;
147
+ color: #fff; font-size: 1.5rem; }
148
+ h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
149
+ p { margin: 0; opacity: .7; }
150
+ </style></head>
151
+ <body><div class="card">
152
+ <div class="mark">${accent === "#2f7d4f" ? "&check;" : "!"}</div>
153
+ <h1>${title}</h1>
154
+ <p>${message}</p>
155
+ </div></body></html>`;
156
+ }
157
+
158
+ export const SUCCESS_TEMPLATE = page(
159
+ "Signed in to Teams",
160
+ "pi can now act as you in Microsoft Teams. You can close this tab and go back to your terminal.",
161
+ "#2f7d4f",
162
+ );
163
+
164
+ export const ERROR_TEMPLATE = page(
165
+ "Sign-in failed",
166
+ "Microsoft rejected the sign-in. Close this tab and check the error in your terminal.",
167
+ "#b03a2e",
168
+ );