@patimweb/pi-teams 1.0.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/LICENSE +21 -0
- package/README.md +412 -0
- package/index.ts +1 -0
- package/package.json +61 -0
- package/prompts/teams-catch-up.md +24 -0
- package/prompts/teams-channel-digest.md +23 -0
- package/prompts/teams-doctor.md +21 -0
- package/prompts/teams-meeting-prep.md +22 -0
- package/prompts/teams-standup.md +21 -0
- package/prompts/teams-triage.md +22 -0
- package/skills/teams-collaboration/SKILL.md +143 -0
- package/src/auth/client-credentials.ts +72 -0
- package/src/auth/device-code.ts +276 -0
- package/src/auth/index.ts +105 -0
- package/src/auth/jwt.ts +50 -0
- package/src/auth/token-store.ts +156 -0
- package/src/config/index.ts +685 -0
- package/src/config/scope.ts +272 -0
- package/src/extension/index.ts +336 -0
- package/src/graph/calendar.ts +165 -0
- package/src/graph/chats.ts +159 -0
- package/src/graph/client.ts +234 -0
- package/src/graph/mappers.ts +268 -0
- package/src/graph/me.ts +80 -0
- package/src/graph/messages.ts +383 -0
- package/src/graph/presence.ts +160 -0
- package/src/graph/search.ts +111 -0
- package/src/graph/teams.ts +271 -0
- package/src/safety/audit.ts +57 -0
- package/src/safety/index.ts +186 -0
- package/src/status.ts +90 -0
- package/src/tools/resolve.ts +138 -0
- package/src/tools/shared.ts +124 -0
- package/src/tools/teams-accounts.ts +169 -0
- package/src/tools/teams-create-channel.ts +89 -0
- package/src/tools/teams-create-chat.ts +112 -0
- package/src/tools/teams-delete-message.ts +114 -0
- package/src/tools/teams-doctor.ts +182 -0
- package/src/tools/teams-find-user.ts +64 -0
- package/src/tools/teams-inbox.ts +142 -0
- package/src/tools/teams-list-channels.ts +62 -0
- package/src/tools/teams-list-chats.ts +74 -0
- package/src/tools/teams-list-files.ts +54 -0
- package/src/tools/teams-list-members.ts +72 -0
- package/src/tools/teams-list-teams.ts +65 -0
- package/src/tools/teams-login.ts +199 -0
- package/src/tools/teams-meetings.ts +342 -0
- package/src/tools/teams-permissions.ts +169 -0
- package/src/tools/teams-presence.ts +216 -0
- package/src/tools/teams-react.ts +109 -0
- package/src/tools/teams-read-channel.ts +75 -0
- package/src/tools/teams-read-chat.ts +73 -0
- package/src/tools/teams-read-thread.ts +88 -0
- package/src/tools/teams-search-messages.ts +71 -0
- package/src/tools/teams-send-channel-message.ts +250 -0
- package/src/tools/teams-send-chat-message.ts +152 -0
- package/src/tools/teams-setup.ts +245 -0
- package/src/tools/teams-status.ts +73 -0
- package/src/tools/tool-names.ts +30 -0
- package/src/types.ts +192 -0
- package/src/utils/errors.ts +133 -0
- package/src/utils/formatting.ts +356 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: teams-collaboration
|
|
3
|
+
description: Microsoft Teams chats, channels, meetings and presence, acting as the signed-in user. Use when the user asks to read, search, summarize, send or reply to Teams messages; to catch up on what they missed; to post in a channel or a thread; to start a chat; to react to a message; to check or set their Teams status; to see who is available; or to schedule, change or cancel a Teams meeting. Also covers multiple Teams accounts and multiple company tenants, and the allow/deny rules that decide what pi may touch.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Microsoft Teams
|
|
7
|
+
|
|
8
|
+
pi acts **as the signed-in user**. A message it sends is indistinguishable from
|
|
9
|
+
one the user typed: same name, same avatar, same thread. Treat every write as
|
|
10
|
+
something the user is personally saying.
|
|
11
|
+
|
|
12
|
+
## Before anything else
|
|
13
|
+
|
|
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` — it prints a short
|
|
16
|
+
code and a URL that the user must open themselves.
|
|
17
|
+
|
|
18
|
+
If a tool fails with an authentication, permission or configuration error, run
|
|
19
|
+
`teams_doctor` and report what it says. Do not retry the failing call.
|
|
20
|
+
|
|
21
|
+
## Multiple accounts and companies
|
|
22
|
+
|
|
23
|
+
Every tool accepts `account` and `tenant`:
|
|
24
|
+
|
|
25
|
+
- `account` — a configured Teams identity (e.g. `work`, `private`)
|
|
26
|
+
- `tenant` — a tenant beneath that account: a customer, a guest tenant, a
|
|
27
|
+
subsidiary
|
|
28
|
+
|
|
29
|
+
Omit both to use the default. Use `teams_accounts` to list what is configured.
|
|
30
|
+
When the user says "in the customer's Teams" or names a company, pass the
|
|
31
|
+
matching `tenant` rather than assuming the default is right.
|
|
32
|
+
|
|
33
|
+
## What pi may do
|
|
34
|
+
|
|
35
|
+
Three independent gates apply:
|
|
36
|
+
|
|
37
|
+
1. **Auth mode** — an app-only account cannot post as a person at all.
|
|
38
|
+
2. **Safety level** — `readonly` blocks writes, `confirm` asks the user first,
|
|
39
|
+
`open` proceeds.
|
|
40
|
+
3. **Scope rules** — allow/deny lists per category (teams, channels, chats,
|
|
41
|
+
people) and per mode (read, write).
|
|
42
|
+
|
|
43
|
+
When a call is refused with "Blocked by configuration", that is the user's own
|
|
44
|
+
rule set. Show them the rule, do not work around it, and do not try a different
|
|
45
|
+
tool to achieve the same thing. `teams_permissions` with `action: "test"`
|
|
46
|
+
checks a target without touching it.
|
|
47
|
+
|
|
48
|
+
## Tools
|
|
49
|
+
|
|
50
|
+
### Session
|
|
51
|
+
|
|
52
|
+
| Tool | Use when |
|
|
53
|
+
|------|----------|
|
|
54
|
+
| `teams_status` | Which account is active, who pi acts as, what it may do |
|
|
55
|
+
| `teams_login` | Not signed in, or adding an account/tenant |
|
|
56
|
+
| `teams_logout` | Removing a saved session |
|
|
57
|
+
| `teams_accounts` | Listing accounts, switching the default, changing a safety level |
|
|
58
|
+
| `teams_permissions` | Showing, testing or changing the allow/deny rules |
|
|
59
|
+
| `teams_doctor` | Anything failed and you need to know why |
|
|
60
|
+
| `teams_setup` | Adding a new account or tenant to the configuration |
|
|
61
|
+
|
|
62
|
+
### Reading
|
|
63
|
+
|
|
64
|
+
| Tool | Use when |
|
|
65
|
+
|------|----------|
|
|
66
|
+
| `teams_inbox` | "What did I miss?", "anything new in Teams?" |
|
|
67
|
+
| `teams_list_chats` | Finding a chat, seeing recent conversations |
|
|
68
|
+
| `teams_read_chat` | Reading one conversation |
|
|
69
|
+
| `teams_list_teams` / `teams_list_channels` | Discovering team and channel names |
|
|
70
|
+
| `teams_read_channel` | The posts in a channel (openers only) |
|
|
71
|
+
| `teams_read_thread` | One thread, opener plus every reply |
|
|
72
|
+
| `teams_search_messages` | Finding something said, when the location is unknown |
|
|
73
|
+
| `teams_list_members` | Who is in a team or channel |
|
|
74
|
+
| `teams_find_user` | Resolving a first name to a real person |
|
|
75
|
+
| `teams_list_files` | Files shared in a channel |
|
|
76
|
+
| `teams_get_presence` | Is someone available right now |
|
|
77
|
+
| `teams_list_meetings` / `teams_get_meeting` | Calendar and meeting details |
|
|
78
|
+
|
|
79
|
+
### Writing
|
|
80
|
+
|
|
81
|
+
| Tool | Use when |
|
|
82
|
+
|------|----------|
|
|
83
|
+
| `teams_send_chat_message` | Sending a chat message |
|
|
84
|
+
| `teams_create_chat` | Starting a new conversation |
|
|
85
|
+
| `teams_send_channel_message` | Posting a new channel message |
|
|
86
|
+
| `teams_reply_channel_message` | Answering inside an existing thread |
|
|
87
|
+
| `teams_react` | Adding or removing an emoji reaction |
|
|
88
|
+
| `teams_delete_message` | Taking back something the user sent |
|
|
89
|
+
| `teams_create_channel` | Adding a channel to a team |
|
|
90
|
+
| `teams_set_presence` / `teams_set_status_message` | Changing the user's status |
|
|
91
|
+
| `teams_create_meeting` / `teams_update_meeting` / `teams_cancel_meeting` | Scheduling work |
|
|
92
|
+
|
|
93
|
+
## How to write as the user
|
|
94
|
+
|
|
95
|
+
- Write in **their** voice. No "As an AI…", no signature, no note that a model
|
|
96
|
+
composed it, unless they ask for one.
|
|
97
|
+
- Match the register of the conversation you just read. A channel post and a
|
|
98
|
+
quick reply to a colleague are not the same thing.
|
|
99
|
+
- Show the exact text and the destination before sending whenever the request
|
|
100
|
+
leaves any room for interpretation. Once it is sent, it is sent.
|
|
101
|
+
- Never invent facts, commitments, dates or agreement on the user's behalf. If
|
|
102
|
+
a detail is missing, ask.
|
|
103
|
+
- Prefer `teams_reply_channel_message` over a new post when answering something
|
|
104
|
+
— starting a second thread fragments the conversation.
|
|
105
|
+
|
|
106
|
+
## Channel addressing
|
|
107
|
+
|
|
108
|
+
Channels are addressed as `Team/Channel` or via separate `team` and `channel`
|
|
109
|
+
parameters. Names are resolved case-insensitively; an ambiguous name is
|
|
110
|
+
reported rather than guessed, and the answer is to pass the ID.
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
teams_send_channel_message:
|
|
114
|
+
channel: "Engineering/General"
|
|
115
|
+
body: "Deployment finished, release notes are in the wiki."
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Mentions
|
|
119
|
+
|
|
120
|
+
Pass `mentions` with names, UPNs or e-mail addresses, and write `@Display Name`
|
|
121
|
+
in the body where the mention belongs:
|
|
122
|
+
|
|
123
|
+
```yaml
|
|
124
|
+
teams_send_chat_message:
|
|
125
|
+
chat: "Project Alpha"
|
|
126
|
+
body: "@Anna Schmidt could you take a look before the standup?"
|
|
127
|
+
mentions: ["anna@contoso.com"]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Resolve names with `teams_find_user` first when only a first name is known.
|
|
131
|
+
|
|
132
|
+
## Threads
|
|
133
|
+
|
|
134
|
+
Channel conversations are threaded. `teams_read_channel` returns the openers
|
|
135
|
+
with their IDs; `teams_read_thread` returns one thread in full. To summarize a
|
|
136
|
+
channel properly, read the openers, then the threads that matter — do not
|
|
137
|
+
summarize from openers alone.
|
|
138
|
+
|
|
139
|
+
## Reading before writing
|
|
140
|
+
|
|
141
|
+
Before replying anywhere, read the recent messages first. It prevents
|
|
142
|
+
answering a question that was already answered, and it is what tells you the
|
|
143
|
+
tone to match.
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth resolver — hands the Graph layer a valid bearer token.
|
|
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`
|
|
8
|
+
*
|
|
9
|
+
* App-only connections skip the cache-refresh dance; a new token is cheap and
|
|
10
|
+
* there is no user to interrupt.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
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";
|
|
17
|
+
|
|
18
|
+
/** Raised when there is no usable token and only the user can fix it. */
|
|
19
|
+
export class NotSignedInError extends Error {
|
|
20
|
+
readonly account: string;
|
|
21
|
+
readonly tenant: string;
|
|
22
|
+
constructor(account: string, tenant: string, detail?: string) {
|
|
23
|
+
super(
|
|
24
|
+
`Not signed in to Teams account "${account}"${tenant !== account ? ` (tenant ${tenant})` : ""}. ` +
|
|
25
|
+
`Run the teams_login tool or the /teams-login command.${detail ? ` (${detail})` : ""}`,
|
|
26
|
+
);
|
|
27
|
+
this.name = "NotSignedInError";
|
|
28
|
+
this.account = account;
|
|
29
|
+
this.tenant = tenant;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** In-process cache so a burst of Graph calls does not re-read the token file. */
|
|
34
|
+
const memoryCache = new Map<string, CachedToken>();
|
|
35
|
+
|
|
36
|
+
function memKey(conn: TeamsConnection): string {
|
|
37
|
+
return `${conn.account.toLowerCase()}::${conn.tenantId.toLowerCase()}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get a bearer token for this connection, refreshing it when needed.
|
|
42
|
+
*
|
|
43
|
+
* @throws {NotSignedInError} when a delegated sign-in is required
|
|
44
|
+
* @throws {AuthError} when the identity provider refuses
|
|
45
|
+
*/
|
|
46
|
+
export async function getAccessToken(
|
|
47
|
+
conn: TeamsConnection,
|
|
48
|
+
signal?: AbortSignal,
|
|
49
|
+
): Promise<CachedToken> {
|
|
50
|
+
const key = memKey(conn);
|
|
51
|
+
|
|
52
|
+
const inMemory = memoryCache.get(key);
|
|
53
|
+
if (isFresh(inMemory)) return inMemory!;
|
|
54
|
+
|
|
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;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const cached = getToken(conn.account, conn.tenantId);
|
|
63
|
+
|
|
64
|
+
if (isFresh(cached)) {
|
|
65
|
+
memoryCache.set(key, cached!);
|
|
66
|
+
return cached!;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cached?.refreshToken) {
|
|
70
|
+
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;
|
|
78
|
+
} 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;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new NotSignedInError(conn.account, conn.tenant);
|
|
87
|
+
}
|
|
88
|
+
|
|
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
|
+
}
|
|
94
|
+
|
|
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));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Drop every in-process token. */
|
|
101
|
+
export function clearMemoryCache(): void {
|
|
102
|
+
memoryCache.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export { AuthError };
|
package/src/auth/jwt.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JWT claim reader.
|
|
3
|
+
*
|
|
4
|
+
* Used only to show *who* pi is signed in as. The token is never validated
|
|
5
|
+
* here — it came straight from the token endpoint over TLS, and Graph is the
|
|
6
|
+
* authority on whether it is accepted. Nothing security-relevant depends on
|
|
7
|
+
* these claims.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface IdTokenClaims {
|
|
11
|
+
oid?: string;
|
|
12
|
+
tid?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
preferred_username?: string;
|
|
15
|
+
upn?: string;
|
|
16
|
+
scp?: string;
|
|
17
|
+
roles?: string[];
|
|
18
|
+
exp?: number;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Decode the payload of a JWT, or undefined when it is not decodable. */
|
|
23
|
+
export function decodeJwtPayload(token: string): IdTokenClaims | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const parts = token.split(".");
|
|
26
|
+
if (parts.length < 2) return undefined;
|
|
27
|
+
const payload = parts[1]!.replace(/-/g, "+").replace(/_/g, "/");
|
|
28
|
+
const padded = payload.padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
|
|
29
|
+
return JSON.parse(Buffer.from(padded, "base64").toString("utf-8")) as IdTokenClaims;
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function decodeIdToken(idToken: string): IdTokenClaims | undefined {
|
|
36
|
+
return decodeJwtPayload(idToken);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Scopes actually present in an access token (`scp`), or app roles (`roles`)
|
|
41
|
+
* for app-only tokens. `teams_doctor` uses this to tell the user which consent
|
|
42
|
+
* is still missing instead of letting them guess from a 403.
|
|
43
|
+
*/
|
|
44
|
+
export function tokenScopes(accessToken: string): string[] {
|
|
45
|
+
const claims = decodeJwtPayload(accessToken);
|
|
46
|
+
if (!claims) return [];
|
|
47
|
+
if (typeof claims.scp === "string" && claims.scp.length > 0) return claims.scp.split(" ");
|
|
48
|
+
if (Array.isArray(claims.roles)) return claims.roles;
|
|
49
|
+
return [];
|
|
50
|
+
}
|