@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/tools/teams-login.ts
CHANGED
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* teams_login — sign in as the user
|
|
2
|
+
* teams_login — sign in as the user.
|
|
3
|
+
* teams_logout — forget a saved session.
|
|
3
4
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The browser flow is the default: pi opens the Microsoft sign-in page, the
|
|
6
|
+
* user signs in with their own credentials and MFA, and MSAL catches the
|
|
7
|
+
* redirect on a loopback port. Where no browser can be reached — SSH, a
|
|
8
|
+
* container, a headless box — it falls back to the device code flow and says
|
|
9
|
+
* why.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
12
|
import { Type } from "typebox";
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import {
|
|
14
|
+
browserUnavailableReason,
|
|
15
|
+
canOpenBrowser,
|
|
16
|
+
defaultSignInMode,
|
|
17
|
+
signIn,
|
|
18
|
+
signOut,
|
|
19
|
+
signOutAll,
|
|
20
|
+
type DeviceCodeInfo,
|
|
21
|
+
type SignInMode,
|
|
22
|
+
} from "../auth/index.ts";
|
|
15
23
|
import { tokenScopes } from "../auth/jwt.ts";
|
|
16
24
|
import { getMe } from "../graph/me.ts";
|
|
17
25
|
import { formatGraphError } from "../utils/errors.ts";
|
|
@@ -31,30 +39,64 @@ import {
|
|
|
31
39
|
interface LoginParams {
|
|
32
40
|
account?: string;
|
|
33
41
|
tenant?: string;
|
|
42
|
+
mode?: string;
|
|
43
|
+
port?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Update = (update: { content: Array<{ type: "text"; text: string }> }) => void;
|
|
47
|
+
|
|
48
|
+
/** What the user has to do, rendered the moment MSAL tells us. */
|
|
49
|
+
function deviceCodeInstructions(label: string, info: DeviceCodeInfo, why?: string): string {
|
|
50
|
+
return [
|
|
51
|
+
`## Sign in to Teams (${label})`,
|
|
52
|
+
"",
|
|
53
|
+
why ? `_Using the device code flow because ${why}._` : "",
|
|
54
|
+
"",
|
|
55
|
+
`1. Open **${info.verificationUri}**`,
|
|
56
|
+
`2. Enter the code: **${info.userCode}**`,
|
|
57
|
+
"3. Sign in with the account pi should act as.",
|
|
58
|
+
"",
|
|
59
|
+
`The code expires in ${Math.round(info.expiresIn / 60)} minutes. Waiting…`,
|
|
60
|
+
]
|
|
61
|
+
.filter((line) => line !== "")
|
|
62
|
+
.join("\n");
|
|
34
63
|
}
|
|
35
64
|
|
|
36
65
|
export const teamsLoginTool = {
|
|
37
66
|
name: "teams_login",
|
|
38
67
|
description:
|
|
39
|
-
"Sign in to Microsoft Teams as yourself
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
68
|
+
"Sign in to Microsoft Teams as yourself. By default this opens your browser at the Microsoft sign-in " +
|
|
69
|
+
"page and completes automatically — nothing to type. On a machine with no browser (SSH, a container) " +
|
|
70
|
+
"it falls back to the device code flow and shows a short code to enter on another device. " +
|
|
71
|
+
"The session is saved and renewed silently, so this is normally needed once per account. " +
|
|
72
|
+
"Use 'account' and 'tenant' to sign in to a second company or a guest tenant.",
|
|
43
73
|
parameters: Type.Object({
|
|
44
74
|
account: AccountParam,
|
|
45
75
|
tenant: TenantParam,
|
|
76
|
+
mode: Type.Optional(
|
|
77
|
+
Type.String({
|
|
78
|
+
description:
|
|
79
|
+
"Force a flow: 'interactive' (browser) or 'device-code'. Omit to let pi choose for this machine.",
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
82
|
+
port: Type.Optional(
|
|
83
|
+
Type.Number({
|
|
84
|
+
description:
|
|
85
|
+
"Fixed loopback port for the browser redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
46
88
|
}),
|
|
47
89
|
promptSnippet: "Sign in to Microsoft Teams as the user",
|
|
48
90
|
promptGuidelines: [
|
|
49
|
-
"Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account.",
|
|
50
|
-
"
|
|
91
|
+
"Use teams_login when a Teams tool reports that pi is not signed in, or when the user wants to add a second account or tenant.",
|
|
92
|
+
"If the device code flow is used, show the user code and URL to the user verbatim — they cannot finish signing in without them.",
|
|
51
93
|
],
|
|
52
94
|
|
|
53
95
|
async execute(
|
|
54
96
|
_toolCallId: string,
|
|
55
97
|
params: LoginParams,
|
|
56
98
|
signal: AbortSignal | undefined,
|
|
57
|
-
onUpdate:
|
|
99
|
+
onUpdate: Update | undefined,
|
|
58
100
|
ctx: ToolContext,
|
|
59
101
|
): Promise<ToolResult> {
|
|
60
102
|
return run(async () => {
|
|
@@ -63,8 +105,7 @@ export const teamsLoginTool = {
|
|
|
63
105
|
|
|
64
106
|
// App-only needs no user interaction at all.
|
|
65
107
|
if (conn.authMode === "client-credentials") {
|
|
66
|
-
const token = await
|
|
67
|
-
storeToken(conn, token);
|
|
108
|
+
const token = await signIn(conn, { signal });
|
|
68
109
|
return textResult(
|
|
69
110
|
[
|
|
70
111
|
`✅ Acquired an app-only token for ${label}.`,
|
|
@@ -72,50 +113,60 @@ export const teamsLoginTool = {
|
|
|
72
113
|
`Granted app roles: ${tokenScopes(token.accessToken).join(", ") || "(none visible in the token)"}`,
|
|
73
114
|
"",
|
|
74
115
|
"Note: app-only tokens cannot post messages as a person. Switch this account to " +
|
|
75
|
-
'authMode "
|
|
116
|
+
'authMode "interactive" if pi should act as you.',
|
|
76
117
|
].join("\n"),
|
|
77
118
|
{ account: conn.account, tenant: conn.tenant, authMode: "client-credentials" },
|
|
78
119
|
);
|
|
79
120
|
}
|
|
80
121
|
|
|
81
|
-
const
|
|
122
|
+
const requested = params.mode?.toLowerCase();
|
|
123
|
+
if (requested && requested !== "interactive" && requested !== "device-code") {
|
|
124
|
+
return errorResult(`mode must be 'interactive' or 'device-code', not "${params.mode}".`);
|
|
125
|
+
}
|
|
82
126
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"",
|
|
86
|
-
`1. Open **${challenge.verificationUri}**`,
|
|
87
|
-
`2. Enter the code: **${challenge.userCode}**`,
|
|
88
|
-
"3. Sign in with the account pi should act as.",
|
|
89
|
-
"",
|
|
90
|
-
`The code expires at ${new Date(challenge.expiresAt).toLocaleTimeString()}. Waiting…`,
|
|
91
|
-
].join("\n");
|
|
127
|
+
let mode: SignInMode = (requested as SignInMode | undefined) ?? defaultSignInMode();
|
|
128
|
+
let fallbackReason: string | undefined;
|
|
92
129
|
|
|
93
|
-
|
|
130
|
+
// An explicit request for the browser on a machine that has none would
|
|
131
|
+
// hang until the timeout; say so and use the code flow instead.
|
|
132
|
+
if (mode === "interactive" && !canOpenBrowser()) {
|
|
133
|
+
fallbackReason = browserUnavailableReason();
|
|
134
|
+
mode = "device-code";
|
|
135
|
+
} else if (mode === "device-code" && !requested && !canOpenBrowser()) {
|
|
136
|
+
fallbackReason = browserUnavailableReason();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (mode === "interactive") {
|
|
140
|
+
onUpdate?.({
|
|
141
|
+
content: [
|
|
142
|
+
{
|
|
143
|
+
type: "text",
|
|
144
|
+
text: [
|
|
145
|
+
`## Signing in to Teams (${label})`,
|
|
146
|
+
"",
|
|
147
|
+
"Opening your browser. Sign in there with the account pi should act as —",
|
|
148
|
+
"the page closes itself when you are done.",
|
|
149
|
+
].join("\n"),
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
94
154
|
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
const result = await signIn(conn, {
|
|
156
|
+
mode,
|
|
97
157
|
signal,
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
content: [
|
|
104
|
-
{
|
|
105
|
-
type: "text",
|
|
106
|
-
text: `${instructions}\n\nStill waiting for sign-in… (${secondsLeft}s left)`,
|
|
107
|
-
},
|
|
108
|
-
],
|
|
109
|
-
});
|
|
110
|
-
}
|
|
158
|
+
loopbackPort: params.port ?? conn.loopbackPort,
|
|
159
|
+
onDeviceCode: (info) => {
|
|
160
|
+
onUpdate?.({
|
|
161
|
+
content: [{ type: "text", text: deviceCodeInstructions(label, info, fallbackReason) }],
|
|
162
|
+
});
|
|
111
163
|
},
|
|
112
164
|
});
|
|
113
165
|
|
|
114
|
-
storeToken(conn, token);
|
|
115
166
|
clearUserCache();
|
|
116
167
|
|
|
117
|
-
// Confirm the token actually works and report who pi now is.
|
|
118
|
-
let identity =
|
|
168
|
+
// Confirm the token actually works, and report who pi now is.
|
|
169
|
+
let identity = result.account?.username ?? result.account?.name ?? "(unknown)";
|
|
119
170
|
let jobTitle: string | undefined;
|
|
120
171
|
try {
|
|
121
172
|
const me = await getMe(conn, signal);
|
|
@@ -132,16 +183,17 @@ export const teamsLoginTool = {
|
|
|
132
183
|
);
|
|
133
184
|
}
|
|
134
185
|
|
|
135
|
-
const granted = tokenScopes(
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
(scope) => !granted.some((g) => g.toLowerCase() === scope.toLowerCase()),
|
|
186
|
+
const granted = tokenScopes(result.accessToken);
|
|
187
|
+
const missing = conn.scopes.filter(
|
|
188
|
+
(scope) => !granted.some((entry) => entry.toLowerCase() === scope.toLowerCase()),
|
|
139
189
|
);
|
|
140
190
|
|
|
141
191
|
return textResult(
|
|
142
192
|
[
|
|
143
193
|
`✅ Signed in to ${label} as **${identity}**${jobTitle ? ` (${jobTitle})` : ""}.`,
|
|
144
194
|
"",
|
|
195
|
+
`Flow: ${result.mode === "interactive" ? "browser sign-in" : "device code"}` +
|
|
196
|
+
`${fallbackReason ? ` (${fallbackReason})` : ""}`,
|
|
145
197
|
`pi now acts as this user in Teams. Safety level: **${conn.safetyLevel}**.`,
|
|
146
198
|
missing.length > 0
|
|
147
199
|
? `\n⚠️ Not granted: ${missing.join(", ")}\nSome tools will fail until an admin consents to these scopes.`
|
|
@@ -151,6 +203,7 @@ export const teamsLoginTool = {
|
|
|
151
203
|
account: conn.account,
|
|
152
204
|
tenant: conn.tenant,
|
|
153
205
|
user: identity,
|
|
206
|
+
mode: result.mode,
|
|
154
207
|
grantedScopes: granted,
|
|
155
208
|
missingScopes: missing,
|
|
156
209
|
},
|
|
@@ -162,12 +215,12 @@ export const teamsLoginTool = {
|
|
|
162
215
|
export const teamsLogoutTool = {
|
|
163
216
|
name: "teams_logout",
|
|
164
217
|
description:
|
|
165
|
-
"Sign out of a Teams account: removes the saved
|
|
166
|
-
"
|
|
218
|
+
"Sign out of a Teams account: removes the saved session from ~/.pi/agent/pi-teams-tokens/. " +
|
|
219
|
+
"The account stays configured, so teams_login can sign in again.",
|
|
167
220
|
parameters: Type.Object({
|
|
168
221
|
account: AccountParam,
|
|
169
222
|
tenant: TenantParam,
|
|
170
|
-
all: Type.Optional(Type.Boolean({ description: "Remove the
|
|
223
|
+
all: Type.Optional(Type.Boolean({ description: "Remove the saved sessions of every account" })),
|
|
171
224
|
}),
|
|
172
225
|
promptSnippet: "Sign out of a Teams account",
|
|
173
226
|
|
|
@@ -182,14 +235,12 @@ export const teamsLogoutTool = {
|
|
|
182
235
|
clearUserCache();
|
|
183
236
|
|
|
184
237
|
if (params.all) {
|
|
185
|
-
const count =
|
|
186
|
-
|
|
187
|
-
return textResult(`✅ Removed ${count} cached Teams session(s).`, { removed: count });
|
|
238
|
+
const count = signOutAll();
|
|
239
|
+
return textResult(`✅ Removed ${count} saved Teams session(s).`, { removed: count });
|
|
188
240
|
}
|
|
189
241
|
|
|
190
242
|
const conn = connectionFor(ctx, params.account, params.tenant);
|
|
191
|
-
const removed =
|
|
192
|
-
forgetToken(conn);
|
|
243
|
+
const removed = await signOut(conn);
|
|
193
244
|
|
|
194
245
|
return removed
|
|
195
246
|
? textResult(`✅ Signed out of ${connectionLabel(conn)}.`, { account: conn.account })
|
package/src/tools/teams-setup.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface SetupParams {
|
|
|
27
27
|
displayName?: string;
|
|
28
28
|
clientSecret?: string;
|
|
29
29
|
authMode?: string;
|
|
30
|
+
loopbackPort?: number;
|
|
30
31
|
safetyLevel?: string;
|
|
31
32
|
parentAccount?: string;
|
|
32
33
|
setDefault?: boolean;
|
|
@@ -116,7 +117,14 @@ export const teamsSetupTool = {
|
|
|
116
117
|
authMode: Type.Optional(
|
|
117
118
|
Type.String({
|
|
118
119
|
description:
|
|
119
|
-
"'
|
|
120
|
+
"'interactive' (browser sign-in, acts as the user — default), 'device-code' (same, but for machines with no browser), " +
|
|
121
|
+
"'client-credentials' (app-only, cannot post as a person), or 'auto' to decide per machine",
|
|
122
|
+
}),
|
|
123
|
+
),
|
|
124
|
+
loopbackPort: Type.Optional(
|
|
125
|
+
Type.Number({
|
|
126
|
+
description:
|
|
127
|
+
"Fixed port for the browser sign-in redirect. Only needed when the app registration lists an exact redirect URI such as http://localhost:3000.",
|
|
120
128
|
}),
|
|
121
129
|
),
|
|
122
130
|
safetyLevel: Type.Optional(
|
|
@@ -179,6 +187,7 @@ export const teamsSetupTool = {
|
|
|
179
187
|
clientSecret: params.clientSecret,
|
|
180
188
|
authMode: params.authMode as TenantConfig["authMode"],
|
|
181
189
|
scopes: params.scopes,
|
|
190
|
+
loopbackPort: params.loopbackPort,
|
|
182
191
|
safetyLevel,
|
|
183
192
|
permissions,
|
|
184
193
|
};
|
|
@@ -211,8 +220,9 @@ export const teamsSetupTool = {
|
|
|
211
220
|
tenantId: params.tenantId,
|
|
212
221
|
clientId: params.clientId,
|
|
213
222
|
clientSecret: params.clientSecret,
|
|
214
|
-
authMode: (params.authMode as AccountConfig["authMode"]) ?? "
|
|
223
|
+
authMode: (params.authMode as AccountConfig["authMode"]) ?? "interactive",
|
|
215
224
|
scopes: params.scopes,
|
|
225
|
+
loopbackPort: params.loopbackPort,
|
|
216
226
|
safetyLevel: safetyLevel ?? "confirm",
|
|
217
227
|
permissions,
|
|
218
228
|
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
import { buildConnectionCard, formatStatusText } from "../status.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { readCacheSummary } from "../auth/cache-plugin.ts";
|
|
8
8
|
import { readRootConfig } from "../config/index.ts";
|
|
9
9
|
import {
|
|
10
10
|
AccountParam,
|
|
@@ -47,13 +47,13 @@ export const teamsStatusTool = {
|
|
|
47
47
|
...(account.tenants ?? []).map((t) => ({ name: `${account.name}/${t.name}`, tenantId: t.tenantId })),
|
|
48
48
|
];
|
|
49
49
|
for (const entry of tenants) {
|
|
50
|
-
const
|
|
51
|
-
const state =
|
|
52
|
-
? "signed in"
|
|
53
|
-
:
|
|
54
|
-
? "
|
|
55
|
-
: "
|
|
56
|
-
const who =
|
|
50
|
+
const cache = readCacheSummary(account.name, entry.tenantId);
|
|
51
|
+
const state = !cache.present
|
|
52
|
+
? "not signed in"
|
|
53
|
+
: cache.fresh
|
|
54
|
+
? "signed in"
|
|
55
|
+
: "session saved (renews on next use)";
|
|
56
|
+
const who = cache.username ? ` — ${cache.username}` : "";
|
|
57
57
|
others.push(`- ${entry.name}: ${state}${who}`);
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment detection.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately dependency-free: both the config layer and the auth layer need
|
|
5
|
+
* to know whether a browser is reachable, and neither should have to import
|
|
6
|
+
* the other to find out.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Can we realistically put a browser in front of this user?
|
|
11
|
+
*
|
|
12
|
+
* The interactive sign-in needs two things: a browser to open, and a loopback
|
|
13
|
+
* listener that browser can reach. Over SSH both assumptions break — `open`
|
|
14
|
+
* would launch a browser on the *remote* machine, where nobody is looking — so
|
|
15
|
+
* those sessions get the device code flow instead.
|
|
16
|
+
*
|
|
17
|
+
* Set `PI_TEAMS_NO_BROWSER=1` to force that fallback anywhere.
|
|
18
|
+
*/
|
|
19
|
+
export function canOpenBrowser(): boolean {
|
|
20
|
+
if (process.env.PI_TEAMS_NO_BROWSER) return false;
|
|
21
|
+
|
|
22
|
+
// A remote shell: the browser would appear on the wrong machine.
|
|
23
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) return false;
|
|
24
|
+
|
|
25
|
+
if (process.platform === "darwin" || process.platform === "win32") return true;
|
|
26
|
+
|
|
27
|
+
// Linux and the BSDs need a display server for a browser to appear at all.
|
|
28
|
+
return !!(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Why the interactive flow is unavailable, phrased for a user-facing message. */
|
|
32
|
+
export function browserUnavailableReason(): string {
|
|
33
|
+
if (process.env.PI_TEAMS_NO_BROWSER) return "PI_TEAMS_NO_BROWSER is set";
|
|
34
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT) {
|
|
35
|
+
return "this is an SSH session, so the browser would open on the wrong machine";
|
|
36
|
+
}
|
|
37
|
+
if (process.platform !== "darwin" && process.platform !== "win32") {
|
|
38
|
+
return "no display server is available (DISPLAY and WAYLAND_DISPLAY are unset)";
|
|
39
|
+
}
|
|
40
|
+
return "no browser is available";
|
|
41
|
+
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client credentials grant — app-only access, no user involved.
|
|
3
|
-
*
|
|
4
|
-
* Useful for unattended jobs (a nightly digest, a scheduled report) against
|
|
5
|
-
* directory and presence data. It is deliberately *not* a way to post as
|
|
6
|
-
* somebody: Microsoft Graph only accepts app-only writes to chats and channels
|
|
7
|
-
* for migration scenarios (`Teamwork.Migrate.All`), so this extension refuses
|
|
8
|
-
* message sends on an app-only token instead of letting them fail deep inside
|
|
9
|
-
* a Graph call. See ../safety/index.ts.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { TeamsConnection } from "../config/index.ts";
|
|
13
|
-
import { AuthError, tokenUrl } from "./device-code.ts";
|
|
14
|
-
import type { CachedToken } from "./token-store.ts";
|
|
15
|
-
|
|
16
|
-
interface TokenResponse {
|
|
17
|
-
access_token: string;
|
|
18
|
-
expires_in: number;
|
|
19
|
-
scope?: string;
|
|
20
|
-
token_type: string;
|
|
21
|
-
error?: string;
|
|
22
|
-
error_description?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Acquire an app-only token.
|
|
27
|
-
*
|
|
28
|
-
* @throws {AuthError} when the secret is missing, expired, or the app has no
|
|
29
|
-
* admin-consented application permissions.
|
|
30
|
-
*/
|
|
31
|
-
export async function acquireAppToken(
|
|
32
|
-
conn: TeamsConnection,
|
|
33
|
-
signal?: AbortSignal,
|
|
34
|
-
): Promise<CachedToken> {
|
|
35
|
-
if (!conn.clientSecret) {
|
|
36
|
-
throw new AuthError(
|
|
37
|
-
"missing_client_secret",
|
|
38
|
-
`Account "${conn.account}" uses client-credentials but no clientSecret is configured.`,
|
|
39
|
-
);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const body = new URLSearchParams({
|
|
43
|
-
grant_type: "client_credentials",
|
|
44
|
-
client_id: conn.clientId,
|
|
45
|
-
client_secret: conn.clientSecret,
|
|
46
|
-
scope: conn.scopes.length === 1 ? conn.scopes[0]! : "https://graph.microsoft.com/.default",
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
const response = await fetch(tokenUrl(conn.authorityHost, conn.tenantId), {
|
|
50
|
-
method: "POST",
|
|
51
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
52
|
-
body: body.toString(),
|
|
53
|
-
signal,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
const json = (await response.json()) as TokenResponse;
|
|
57
|
-
|
|
58
|
-
if (!response.ok || !json.access_token) {
|
|
59
|
-
throw new AuthError(
|
|
60
|
-
json.error ?? `http_${response.status}`,
|
|
61
|
-
json.error_description ?? `Client credentials request failed with HTTP ${response.status}`,
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
accessToken: json.access_token,
|
|
67
|
-
expiresAt: Date.now() + Math.max(0, json.expires_in - 60) * 1000,
|
|
68
|
-
scopes: json.scope ? json.scope.split(" ") : [],
|
|
69
|
-
authMode: "client-credentials",
|
|
70
|
-
updatedAt: new Date().toISOString(),
|
|
71
|
-
};
|
|
72
|
-
}
|