@michaelschnyder/teams-cli 0.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/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SECURITY.md +21 -0
- package/dist/auth.js +337 -0
- package/dist/cli.js +647 -0
- package/dist/commands/skills.js +70 -0
- package/dist/commands/version.js +16 -0
- package/dist/config.js +106 -0
- package/dist/constants.js +8 -0
- package/dist/data.js +56 -0
- package/dist/diagnostics.js +60 -0
- package/dist/jwt.js +42 -0
- package/dist/oauth.js +146 -0
- package/dist/policy.js +328 -0
- package/dist/skills/teams-authentication/SKILL.md +30 -0
- package/dist/skills/teams-cli/SKILL.md +34 -0
- package/dist/skills/teams-messaging-policies/SKILL.md +26 -0
- package/dist/skills/teams-reading/SKILL.md +29 -0
- package/dist/skills.js +156 -0
- package/dist/storage.js +147 -0
- package/dist/teams-auth.js +54 -0
- package/dist/teams-client.js +604 -0
- package/dist/update.js +137 -0
- package/dist/upgrade.js +45 -0
- package/dist/version.js +7 -0
- package/dist/yaml.js +33 -0
- package/docs/releasing.md +48 -0
- package/docs/use/authentication.md +25 -0
- package/docs/use/policies.md +136 -0
- package/docs/use/profiles.md +57 -0
- package/package.json +64 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.0
|
|
6
|
+
|
|
7
|
+
- Added browser-backed Microsoft Teams authentication with identity-isolated profiles and token refresh.
|
|
8
|
+
- Added people, chat, channel, and message reads with structured JSON output.
|
|
9
|
+
- Added policy-authorized plain-text message sending and subject-path workspace policies.
|
|
10
|
+
- Added packaged agent skills with multi-environment installation and managed refresh.
|
|
11
|
+
- Added deferred npm update notifications and `version --upgrade`.
|
|
12
|
+
- Added cross-platform CI, package validation, CodeQL, Dependabot, and npm publication automation.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael Schnyder
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# teams-cli
|
|
2
|
+
|
|
3
|
+
A safety-conscious command-line client for persistent Microsoft Teams sessions. It supports multiple tenants and users, named profiles, structured output, agent skills, and optional subject-path policies that can prevent messages from being sent to unintended chats or channels.
|
|
4
|
+
|
|
5
|
+
> [!WARNING]
|
|
6
|
+
> This project relies on undocumented Microsoft Teams behavior and a Microsoft first-party client identity. It is unsupported, may be blocked by tenant policy, and may stop working without notice. Obtain organizational approval before using it, and never run live write tests against a production tenant.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Node.js 22.20 or newer. Node.js 24 LTS is recommended.
|
|
11
|
+
- Microsoft Edge or Google Chrome.
|
|
12
|
+
- A Microsoft 365 account with Teams access.
|
|
13
|
+
|
|
14
|
+
### Install Node.js on Windows
|
|
15
|
+
|
|
16
|
+
Open PowerShell and install the current Node.js LTS release with Windows Package Manager:
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
winget install --id OpenJS.NodeJS.LTS --exact --source winget
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Open a new terminal, then verify both tools:
|
|
23
|
+
|
|
24
|
+
```powershell
|
|
25
|
+
node --version
|
|
26
|
+
npm --version
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If `winget` is unavailable, install Microsoft App Installer or use the signed installer from the [Node.js download page](https://nodejs.org/en/download).
|
|
30
|
+
|
|
31
|
+
### Install Node.js on macOS
|
|
32
|
+
|
|
33
|
+
With [Homebrew](https://brew.sh/):
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
brew install node@24
|
|
37
|
+
echo 'export PATH="$(brew --prefix node@24)/bin:$PATH"' >> ~/.zshrc
|
|
38
|
+
source ~/.zshrc
|
|
39
|
+
node --version
|
|
40
|
+
npm --version
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Alternatively, use the signed macOS package from the [Node.js download page](https://nodejs.org/en/download).
|
|
44
|
+
|
|
45
|
+
### Install Node.js on Linux
|
|
46
|
+
|
|
47
|
+
Distribution repositories may contain a Node.js version older than this CLI requires. A version manager keeps the runtime separate from system packages. The following follows the [Node.js download guidance](https://nodejs.org/en/download) using `nvm`; inspect downloaded installation scripts before running them:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -o nvm-install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh
|
|
51
|
+
less nvm-install.sh
|
|
52
|
+
bash nvm-install.sh
|
|
53
|
+
. "$HOME/.nvm/nvm.sh"
|
|
54
|
+
nvm install 24
|
|
55
|
+
node --version
|
|
56
|
+
npm --version
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Node.js also publishes signed standalone Linux archives on its download page.
|
|
60
|
+
|
|
61
|
+
## Install teams-cli
|
|
62
|
+
|
|
63
|
+
Install the command globally:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm install --global @michaelschnyder/teams-cli
|
|
67
|
+
teams-cli --version
|
|
68
|
+
teams-cli --help
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
To try it without a permanent installation:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx @michaelschnyder/teams-cli --help
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
If a global install reports a permissions error, use a Node version manager instead of running npm with `sudo`. If installation succeeds but `teams-cli` is not found, run `npm prefix --global` and ensure that npm's global executable directory is on `PATH`.
|
|
78
|
+
|
|
79
|
+
## Quick start
|
|
80
|
+
|
|
81
|
+
Interactive login opens a dedicated Edge profile by default:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
teams-cli --profile work --tenant YOUR_TENANT_ID auth login
|
|
85
|
+
teams-cli --profile work auth whoami
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The successful login records the verified tenant, user, and browser in the named profile. Subsequent commands reuse identity-isolated tokens and refresh them from the saved browser state when necessary.
|
|
89
|
+
|
|
90
|
+
Discover Teams data:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
teams-cli --profile work person search "Alice" --json
|
|
94
|
+
teams-cli --profile work chat list --json
|
|
95
|
+
teams-cli --profile work channel list --json
|
|
96
|
+
teams-cli --profile work message list --chat CHAT_ID --json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Send a plain-text message after checking the selected identity and target:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
teams-cli --profile work auth whoami
|
|
103
|
+
teams-cli --profile work policy check send --chat CHAT_ID
|
|
104
|
+
teams-cli --profile work message send --chat CHAT_ID --body "Hello"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Use `--channel CHANNEL_ID` instead of `--chat CHAT_ID` for channel messages. A message body can also be piped on stdin.
|
|
108
|
+
|
|
109
|
+
## Command overview
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
teams-cli [--profile NAME] [--tenant ID] [--user ID] [--browser edge|chrome]
|
|
113
|
+
|
|
114
|
+
version show the installed version or upgrade it
|
|
115
|
+
skills list, locate, install, and refresh packaged agent skills
|
|
116
|
+
auth login, refresh, whoami, tokens, logout
|
|
117
|
+
profile list, show, save, remove
|
|
118
|
+
policy init, list, show, check, activate
|
|
119
|
+
person search, get, image
|
|
120
|
+
chat list, get
|
|
121
|
+
channel list, get
|
|
122
|
+
message list, get, send
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Run `teams-cli <command> --help` for complete arguments and options.
|
|
126
|
+
|
|
127
|
+
### Structured output
|
|
128
|
+
|
|
129
|
+
Person, chat, channel, and message commands that return data support `--json`. JSON payloads stay on stdout. Progress, warnings, update notices, and sanitized debug output stay on stderr, so scripts can safely pipe stdout.
|
|
130
|
+
|
|
131
|
+
## Profiles and local state
|
|
132
|
+
|
|
133
|
+
Profiles provide defaults; global flags override them for one command. When no profile is selected, the profile named `default` is used.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
teams-cli profile list
|
|
137
|
+
teams-cli profile show work
|
|
138
|
+
teams-cli --tenant TENANT_ID --user USER_ID --browser chrome profile save work
|
|
139
|
+
teams-cli profile remove work
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Configuration, authentication, browser state, policies, update state, and managed skill-installation records live under `~/.teams-cli/`. Secret-bearing files and directories are created with owner-only permissions on supported operating systems.
|
|
143
|
+
|
|
144
|
+
See [profiles and precedence](docs/use/profiles.md) and [authentication and token handling](docs/use/authentication.md).
|
|
145
|
+
|
|
146
|
+
## Policies
|
|
147
|
+
|
|
148
|
+
Policies are optional. Inactive policies audit and warn without enforcing; active policies enforce the intersection of all matching subject-path rules. A malformed policy store puts authenticated operations into fail-safe mode. Active policies cannot be deactivated through this CLI.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
teams-cli --profile work policy init project-agent
|
|
152
|
+
teams-cli policy show project-agent
|
|
153
|
+
teams-cli policy check send --chat CHAT_ID
|
|
154
|
+
teams-cli policy activate project-agent
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Review and edit the generated allowlists before activation. See [workspace policies](docs/use/policies.md) and the [security model](https://github.com/michaelschnyder/teams-cli/blob/main/docs/build/security-model.md).
|
|
158
|
+
|
|
159
|
+
### Token export
|
|
160
|
+
|
|
161
|
+
The existing authentication commands can print raw bearer tokens or decoded JWT claims:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
teams-cli --profile work auth tokens
|
|
165
|
+
teams-cli --profile work auth token access
|
|
166
|
+
teams-cli --profile work auth tokens --decode
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Applicable active policies can deny raw token export. Treat exported tokens like passwords: another HTTP client can use them outside the CLI's cooperative policy checks. Never paste them into prompts, logs, issues, or source files.
|
|
170
|
+
|
|
171
|
+
## Agent skills
|
|
172
|
+
|
|
173
|
+
The npm package includes skills that teach compatible coding agents how to use this CLI safely. Auto-detection supports Codex, Claude Code, Cursor, GitHub Copilot, OpenCode, Windsurf, Gemini CLI, Pi, and generic `.agents/skills` environments.
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
teams-cli skills list
|
|
177
|
+
teams-cli skills path
|
|
178
|
+
teams-cli skills install
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
When several environments are detected, the skills are installed into all of them. Specify a target when detection is unavailable:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
teams-cli skills install codex
|
|
185
|
+
teams-cli skills install github-copilot --project
|
|
186
|
+
teams-cli skills install agents --name teams-cli
|
|
187
|
+
teams-cli skills install all
|
|
188
|
+
teams-cli skills install --dir /custom/skills
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Initial installation does not replace an existing `SKILL.md`; use `--force` when replacement is intentional. Successful destinations are recorded. Installed copies are managed by the CLI and are refreshed by `teams-cli skills reinstall` and after a successful CLI upgrade, replacing local edits in those copies.
|
|
192
|
+
|
|
193
|
+
## Updates and upgrades
|
|
194
|
+
|
|
195
|
+
At startup, the CLI may launch a detached npm registry check. It runs at most once per hour, does not delay the command, and stores only timestamps and version numbers. If a newer version is found, a notice is printed to stderr on the next invocation.
|
|
196
|
+
|
|
197
|
+
Disable checks with either environment variable:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
export NO_UPDATE_NOTIFIER=1
|
|
201
|
+
# or
|
|
202
|
+
export TEAMS_CLI_DISABLE_UPDATE_CHECK=1
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Checks are automatically disabled in CI. Upgrade the global npm installation and refresh recorded skills with:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
teams-cli version --upgrade
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
This command updates the global npm package. It does not modify a project-local or one-off `npx` installation.
|
|
212
|
+
|
|
213
|
+
## Troubleshooting
|
|
214
|
+
|
|
215
|
+
- `teams-cli: command not found`: check `npm prefix --global` and your `PATH`.
|
|
216
|
+
- Browser launch fails: install Edge or Chrome and select it with `--browser edge|chrome`.
|
|
217
|
+
- Login succeeds but Teams access fails: confirm that the account has a Teams-enabled Microsoft 365 license.
|
|
218
|
+
- Stored identity is rejected: run `auth login` again for the selected tenant, user, and profile.
|
|
219
|
+
- An agent environment is not detected: pass its name explicitly to `skills install`.
|
|
220
|
+
- Use `--debug` for sanitized request method, endpoint, status, duration, and retry diagnostics. Headers, tokens, cookies, query values, and bodies are not logged.
|
|
221
|
+
|
|
222
|
+
## Uninstall
|
|
223
|
+
|
|
224
|
+
Remove the global command:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
npm uninstall --global @michaelschnyder/teams-cli
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Uninstalling the npm package intentionally leaves authentication and configuration data in `~/.teams-cli/`. Run `auth logout` for each stored identity before uninstalling. Remove `~/.teams-cli/` manually only when you intend to delete every remaining profile, token, browser session, policy, update record, and managed-skill record.
|
|
231
|
+
|
|
232
|
+
Installed agent skill copies live outside `~/.teams-cli/` and are not deleted automatically.
|
|
233
|
+
|
|
234
|
+
## Development
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
npm install
|
|
238
|
+
npm run check
|
|
239
|
+
npm test
|
|
240
|
+
npm run build
|
|
241
|
+
npm run package:check
|
|
242
|
+
npm run package:smoke
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
See [CONTRIBUTING.md](https://github.com/michaelschnyder/teams-cli/blob/main/CONTRIBUTING.md), [architecture](https://github.com/michaelschnyder/teams-cli/blob/main/docs/build/architecture.md), [testing](https://github.com/michaelschnyder/teams-cli/blob/main/docs/build/testing.md), and the [release guide](docs/releasing.md).
|
|
246
|
+
|
|
247
|
+
## License
|
|
248
|
+
|
|
249
|
+
[MIT](LICENSE)
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Do not open a public issue containing credentials, bearer tokens, tenant data, private Teams content, or an exploitable vulnerability. Report security concerns privately through GitHub's security-advisory feature for `michaelschnyder/teams-cli`. Include the affected version, impact, reproduction steps with synthetic data, and any suggested mitigation.
|
|
6
|
+
|
|
7
|
+
## Security model
|
|
8
|
+
|
|
9
|
+
This CLI stores bearer tokens and a dedicated browser profile locally so a Teams session can persist. Those assets are sensitive and are protected with owner-only filesystem permissions where the operating system supports them. Debug logging excludes authentication material and request or response bodies.
|
|
10
|
+
|
|
11
|
+
Workspace policies are cooperative client-side controls intended to prevent accidental messages and token export. They do not constrain another process running as the same operating-system user, an exported token used by another HTTP client, or a user who can replace the policy files. Stronger enforcement requires operating-system isolation, read-only mounts, restricted network egress, least-privilege Microsoft accounts, or server-side Teams controls.
|
|
12
|
+
|
|
13
|
+
The CLI uses undocumented Microsoft Teams behavior and a Microsoft first-party client identity. It must not be used to bypass MFA, conditional access, tenant policy, organizational approval, or Microsoft authorization.
|
|
14
|
+
|
|
15
|
+
The hourly update check contacts only the npm registry, is disabled in CI, and can be disabled with `NO_UPDATE_NOTIFIER=1` or `TEAMS_CLI_DISABLE_UPDATE_CHECK=1`. No telemetry is collected.
|
|
16
|
+
|
|
17
|
+
Packaged agent skills are instruction files, not executable plugins. Review skills before installing them. The supplied skills do not pre-authorize shell commands and explicitly preserve Teams identity, target, and policy checks.
|
|
18
|
+
|
|
19
|
+
## Supported versions
|
|
20
|
+
|
|
21
|
+
Security fixes are provided for the latest published npm version. Upgrade with `teams-cli version --upgrade` or `npm install --global @michaelschnyder/teams-cli@latest`.
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { CHAT_SVC_AGG_RESOURCE, OUTLOOK_SEARCH_RESOURCE, SKYPE_RESOURCE, } from "./constants.js";
|
|
2
|
+
import { readJwtMetadata, secondsUntil } from "./jwt.js";
|
|
3
|
+
import { acquireResourceTokens, OAuthRedirectError, } from "./oauth.js";
|
|
4
|
+
import { clearAuthentication, discardStagingBrowserProfile, loadSession, prepareBrowserProfile, prepareStagingBrowserProfile, promoteStagingBrowserProfile, requireCurrentSession, saveSession, } from "./storage.js";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { isAbsolute } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { exchangeInitialToken, TeamsAuthError, } from "./teams-auth.js";
|
|
9
|
+
const ALL_RESOURCES = [SKYPE_RESOURCE, CHAT_SVC_AGG_RESOURCE, OUTLOOK_SEARCH_RESOURCE];
|
|
10
|
+
const defaultDependencies = {
|
|
11
|
+
acquireTokens: acquireResourceTokens,
|
|
12
|
+
exchangeToken: exchangeInitialToken,
|
|
13
|
+
now: () => new Date(),
|
|
14
|
+
};
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
export async function passwordFromCommand(command) {
|
|
17
|
+
if (!isAbsolute(command))
|
|
18
|
+
throw new Error("--password-command must be an absolute executable path");
|
|
19
|
+
let stdout;
|
|
20
|
+
try {
|
|
21
|
+
({ stdout } = await execFileAsync(command, [], {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
timeout: 30_000,
|
|
24
|
+
maxBuffer: 16 * 1024,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new Error("Password helper failed");
|
|
30
|
+
}
|
|
31
|
+
const password = stdout.replace(/\r?\n$/, "");
|
|
32
|
+
if (!password.length)
|
|
33
|
+
throw new Error("Password helper returned an empty password");
|
|
34
|
+
return password;
|
|
35
|
+
}
|
|
36
|
+
function tokenExpiry(metadata, expiresIn, now = new Date()) {
|
|
37
|
+
if (metadata.expiresAt)
|
|
38
|
+
return metadata.expiresAt;
|
|
39
|
+
if (expiresIn !== undefined)
|
|
40
|
+
return new Date(now.getTime() + expiresIn * 1000).toISOString();
|
|
41
|
+
throw new Error("Teams returned a token without an expiry");
|
|
42
|
+
}
|
|
43
|
+
function validateTokenIdentity(token, expectedTenant, expectedUser) {
|
|
44
|
+
const metadata = readJwtMetadata(token);
|
|
45
|
+
if (!metadata.tenantId)
|
|
46
|
+
throw new Error("Teams returned a token without a tenant ID");
|
|
47
|
+
if (metadata.tenantId !== expectedTenant) {
|
|
48
|
+
throw new Error(`Teams returned a token for unexpected tenant ${metadata.tenantId}`);
|
|
49
|
+
}
|
|
50
|
+
if (expectedUser && metadata.userId && metadata.userId !== expectedUser) {
|
|
51
|
+
throw new Error(`Teams returned a token for unexpected user ${metadata.userId}`);
|
|
52
|
+
}
|
|
53
|
+
return metadata;
|
|
54
|
+
}
|
|
55
|
+
function requireResource(tokens, resource) {
|
|
56
|
+
const token = tokens.get(resource);
|
|
57
|
+
if (!token)
|
|
58
|
+
throw new Error(`Microsoft login returned no token for ${resource}`);
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
function createStoredSession(tokens, exchanged, browser, expectedTenant, now) {
|
|
62
|
+
const accessToken = requireResource(tokens, SKYPE_RESOURCE);
|
|
63
|
+
const chatToken = requireResource(tokens, CHAT_SVC_AGG_RESOURCE);
|
|
64
|
+
const searchToken = requireResource(tokens, OUTLOOK_SEARCH_RESOURCE);
|
|
65
|
+
const access = readJwtMetadata(accessToken);
|
|
66
|
+
const skype = readJwtMetadata(exchanged.skypeToken);
|
|
67
|
+
const tenantId = access.tenantId ?? skype.tenantId;
|
|
68
|
+
if (!tenantId)
|
|
69
|
+
throw new Error("Teams returned tokens without a tenant ID");
|
|
70
|
+
const userId = access.userId;
|
|
71
|
+
if (!userId)
|
|
72
|
+
throw new Error("Teams returned an access token without a user ID");
|
|
73
|
+
if (expectedTenant && expectedTenant !== tenantId) {
|
|
74
|
+
throw new Error(`Teams returned a token for unexpected tenant ${tenantId}`);
|
|
75
|
+
}
|
|
76
|
+
for (const token of [exchanged.skypeToken, chatToken, searchToken]) {
|
|
77
|
+
validateTokenIdentity(token, tenantId, userId);
|
|
78
|
+
}
|
|
79
|
+
if (!exchanged.region)
|
|
80
|
+
throw new Error("Teams auth exchange returned no region");
|
|
81
|
+
if (!exchanged.endpoints.chatService) {
|
|
82
|
+
throw new Error("Teams auth exchange returned no regional chat service endpoint");
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
version: 3,
|
|
86
|
+
browser,
|
|
87
|
+
tenantId,
|
|
88
|
+
userId,
|
|
89
|
+
...(access.username ? { username: access.username } : {}),
|
|
90
|
+
savedAt: now.toISOString(),
|
|
91
|
+
region: exchanged.region,
|
|
92
|
+
accessToken: { value: accessToken, expiresAt: tokenExpiry(access, undefined, now) },
|
|
93
|
+
skypeToken: {
|
|
94
|
+
value: exchanged.skypeToken,
|
|
95
|
+
expiresAt: tokenExpiry(skype, exchanged.expiresIn, now),
|
|
96
|
+
},
|
|
97
|
+
chatToken: {
|
|
98
|
+
value: chatToken,
|
|
99
|
+
expiresAt: tokenExpiry(readJwtMetadata(chatToken), undefined, now),
|
|
100
|
+
},
|
|
101
|
+
searchToken: {
|
|
102
|
+
value: searchToken,
|
|
103
|
+
expiresAt: tokenExpiry(readJwtMetadata(searchToken), undefined, now),
|
|
104
|
+
},
|
|
105
|
+
endpoints: {
|
|
106
|
+
chatService: exchanged.endpoints.chatService,
|
|
107
|
+
...(exchanged.endpoints.chatServiceAggregator
|
|
108
|
+
? { chatServiceAggregator: exchanged.endpoints.chatServiceAggregator }
|
|
109
|
+
: {}),
|
|
110
|
+
...(exchanged.endpoints.middleTier ? { middleTier: exchanged.endpoints.middleTier } : {}),
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function interactiveRefreshError(error) {
|
|
115
|
+
if (error instanceof OAuthRedirectError) {
|
|
116
|
+
throw new Error(`Microsoft could not refresh the session without interaction (${error.code}). Run \`teams-cli auth login\`.`);
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
async function acquireAndExchange(paths, options, dependencies, expectedIdentity = {}) {
|
|
121
|
+
const knownIdentity = !options.interactive && expectedIdentity.tenantId && expectedIdentity.userId
|
|
122
|
+
? { tenantId: expectedIdentity.tenantId, userId: expectedIdentity.userId }
|
|
123
|
+
: null;
|
|
124
|
+
const staging = knownIdentity ? null : await prepareStagingBrowserProfile(paths, options.browser);
|
|
125
|
+
const profileDirectory = knownIdentity
|
|
126
|
+
? await prepareBrowserProfile(paths, knownIdentity, options.browser)
|
|
127
|
+
: staging?.directory;
|
|
128
|
+
try {
|
|
129
|
+
const acquired = await dependencies.acquireTokens(ALL_RESOURCES, { ...options, profileDirectory });
|
|
130
|
+
let session;
|
|
131
|
+
try {
|
|
132
|
+
const exchanged = await dependencies.exchangeToken(requireResource(acquired.tokens, SKYPE_RESOURCE));
|
|
133
|
+
session = createStoredSession(acquired.tokens, exchanged, options.browser, expectedIdentity.tenantId, dependencies.now());
|
|
134
|
+
if (expectedIdentity.userId && expectedIdentity.userId !== session.userId) {
|
|
135
|
+
throw new Error(`Microsoft login returned unexpected user ${session.userId}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await acquired.close();
|
|
140
|
+
}
|
|
141
|
+
if (staging) {
|
|
142
|
+
await promoteStagingBrowserProfile(paths, staging.identifier, session, options.browser);
|
|
143
|
+
}
|
|
144
|
+
return session;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (staging)
|
|
148
|
+
await discardStagingBrowserProfile(paths, staging.identifier);
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export async function login(paths, options, dependencies = defaultDependencies) {
|
|
153
|
+
if (options.password && options.passwordCommand) {
|
|
154
|
+
throw new Error("Provide either a password or --password-command, not both");
|
|
155
|
+
}
|
|
156
|
+
if ((options.password || options.passwordCommand) && !options.username) {
|
|
157
|
+
throw new Error("Automated login requires a username");
|
|
158
|
+
}
|
|
159
|
+
if (options.headless && !options.password && !options.passwordCommand) {
|
|
160
|
+
throw new Error("--headless login requires automated credentials");
|
|
161
|
+
}
|
|
162
|
+
const password = options.password ?? (options.passwordCommand
|
|
163
|
+
? await passwordFromCommand(options.passwordCommand)
|
|
164
|
+
: undefined);
|
|
165
|
+
const session = await acquireAndExchange(paths, {
|
|
166
|
+
browser: options.browser,
|
|
167
|
+
interactive: true,
|
|
168
|
+
...(options.headless ? { headless: true } : {}),
|
|
169
|
+
...(options.username ? { username: options.username } : {}),
|
|
170
|
+
...(password ? { password } : {}),
|
|
171
|
+
...(options.tenant ? { tenant: options.tenant } : {}),
|
|
172
|
+
}, dependencies, {
|
|
173
|
+
...(options.tenant ? { tenantId: options.tenant } : {}),
|
|
174
|
+
...(options.user ? { userId: options.user } : {}),
|
|
175
|
+
});
|
|
176
|
+
if (options.authorizeIdentity)
|
|
177
|
+
await options.authorizeIdentity(session);
|
|
178
|
+
await saveSession(paths, session);
|
|
179
|
+
return session;
|
|
180
|
+
}
|
|
181
|
+
async function refreshAll(paths, session, dependencies, browser) {
|
|
182
|
+
try {
|
|
183
|
+
return await acquireAndExchange(paths, { browser, interactive: false, tenant: session.tenantId }, dependencies, { tenantId: session.tenantId, userId: session.userId });
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
interactiveRefreshError(error);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
async function acquireOneResource(paths, session, resource, dependencies, browser) {
|
|
190
|
+
const profileDirectory = await prepareBrowserProfile(paths, session, browser);
|
|
191
|
+
try {
|
|
192
|
+
const acquired = await dependencies.acquireTokens([resource], {
|
|
193
|
+
browser,
|
|
194
|
+
interactive: false,
|
|
195
|
+
tenant: session.tenantId,
|
|
196
|
+
profileDirectory,
|
|
197
|
+
});
|
|
198
|
+
try {
|
|
199
|
+
const token = requireResource(acquired.tokens, resource);
|
|
200
|
+
const metadata = validateTokenIdentity(token, session.tenantId, session.userId);
|
|
201
|
+
return { value: token, expiresAt: tokenExpiry(metadata, undefined, dependencies.now()) };
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await acquired.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
interactiveRefreshError(error);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async function refreshAccess(paths, session, dependencies, browser) {
|
|
212
|
+
return {
|
|
213
|
+
...session,
|
|
214
|
+
savedAt: dependencies.now().toISOString(),
|
|
215
|
+
browser,
|
|
216
|
+
accessToken: await acquireOneResource(paths, session, SKYPE_RESOURCE, dependencies, browser),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function refreshOAuthTarget(paths, session, target, dependencies, browser) {
|
|
220
|
+
const resource = target === "chat" ? CHAT_SVC_AGG_RESOURCE : OUTLOOK_SEARCH_RESOURCE;
|
|
221
|
+
const token = await acquireOneResource(paths, session, resource, dependencies, browser);
|
|
222
|
+
return {
|
|
223
|
+
...session,
|
|
224
|
+
browser,
|
|
225
|
+
savedAt: dependencies.now().toISOString(),
|
|
226
|
+
...(target === "chat" ? { chatToken: token } : { searchToken: token }),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async function refreshSkype(session, dependencies) {
|
|
230
|
+
if (secondsUntil(session.accessToken.expiresAt, dependencies.now()) === 0) {
|
|
231
|
+
throw new Error("Cannot refresh the Skype token because the access token has expired. Refresh `all` or `access` first.");
|
|
232
|
+
}
|
|
233
|
+
const exchanged = await dependencies.exchangeToken(session.accessToken.value);
|
|
234
|
+
const metadata = validateTokenIdentity(exchanged.skypeToken, session.tenantId, session.userId);
|
|
235
|
+
if (!exchanged.region || !exchanged.endpoints.chatService) {
|
|
236
|
+
throw new Error("Teams auth exchange returned an incomplete regional endpoint map");
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
...session,
|
|
240
|
+
savedAt: dependencies.now().toISOString(),
|
|
241
|
+
region: exchanged.region,
|
|
242
|
+
skypeToken: {
|
|
243
|
+
value: exchanged.skypeToken,
|
|
244
|
+
expiresAt: tokenExpiry(metadata, exchanged.expiresIn, dependencies.now()),
|
|
245
|
+
},
|
|
246
|
+
endpoints: {
|
|
247
|
+
chatService: exchanged.endpoints.chatService,
|
|
248
|
+
...(exchanged.endpoints.chatServiceAggregator
|
|
249
|
+
? { chatServiceAggregator: exchanged.endpoints.chatServiceAggregator }
|
|
250
|
+
: {}),
|
|
251
|
+
...(exchanged.endpoints.middleTier ? { middleTier: exchanged.endpoints.middleTier } : {}),
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
export async function refreshTokens(paths, identity, target = "all", browser, dependencies = defaultDependencies) {
|
|
256
|
+
const before = requireCurrentSession(await loadSession(paths, identity));
|
|
257
|
+
const selectedBrowser = browser ?? before.browser;
|
|
258
|
+
const session = target === "all"
|
|
259
|
+
? await refreshAll(paths, before, dependencies, selectedBrowser)
|
|
260
|
+
: target === "access"
|
|
261
|
+
? await refreshAccess(paths, before, dependencies, selectedBrowser)
|
|
262
|
+
: target === "skype"
|
|
263
|
+
? await refreshSkype(before, dependencies)
|
|
264
|
+
: await refreshOAuthTarget(paths, before, target, dependencies, selectedBrowser);
|
|
265
|
+
await saveSession(paths, session);
|
|
266
|
+
return { target, before, after: session };
|
|
267
|
+
}
|
|
268
|
+
export async function validateSession(paths, identity, browser, dependencies = defaultDependencies) {
|
|
269
|
+
const stored = requireCurrentSession(await loadSession(paths, identity));
|
|
270
|
+
const selectedBrowser = browser ?? stored.browser;
|
|
271
|
+
let session;
|
|
272
|
+
if (secondsUntil(stored.accessToken.expiresAt, dependencies.now()) === 0) {
|
|
273
|
+
session = await refreshAll(paths, stored, dependencies, selectedBrowser);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
try {
|
|
277
|
+
session = await refreshSkype(stored, dependencies);
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (!(error instanceof TeamsAuthError) || (error.status !== 401 && error.status !== 403)) {
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
session = await refreshAll(paths, stored, dependencies, selectedBrowser);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
await saveSession(paths, session);
|
|
287
|
+
return session;
|
|
288
|
+
}
|
|
289
|
+
function tokenForTarget(session, target) {
|
|
290
|
+
return target === "access"
|
|
291
|
+
? session.accessToken
|
|
292
|
+
: target === "skype"
|
|
293
|
+
? session.skypeToken
|
|
294
|
+
: target === "chat"
|
|
295
|
+
? session.chatToken
|
|
296
|
+
: session.searchToken;
|
|
297
|
+
}
|
|
298
|
+
export async function ensureDataSession(paths, identity, target, browser, dependencies = defaultDependencies) {
|
|
299
|
+
const session = requireCurrentSession(await loadSession(paths, identity));
|
|
300
|
+
if (secondsUntil(tokenForTarget(session, target).expiresAt, dependencies.now()) > 60) {
|
|
301
|
+
return session;
|
|
302
|
+
}
|
|
303
|
+
if (target === "skype" && secondsUntil(session.accessToken.expiresAt, dependencies.now()) <= 60) {
|
|
304
|
+
await refreshTokens(paths, identity, "access", browser, dependencies);
|
|
305
|
+
}
|
|
306
|
+
return (await refreshTokens(paths, identity, target, browser, dependencies)).after;
|
|
307
|
+
}
|
|
308
|
+
function tokenResult(token, now) {
|
|
309
|
+
const metadata = readJwtMetadata(token.value);
|
|
310
|
+
return {
|
|
311
|
+
value: token.value,
|
|
312
|
+
audience: metadata.audience ?? null,
|
|
313
|
+
expiresAt: token.expiresAt,
|
|
314
|
+
expiresInSeconds: secondsUntil(token.expiresAt, now),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
export function describeSession(session, now = new Date()) {
|
|
318
|
+
const identity = readJwtMetadata(session.accessToken.value);
|
|
319
|
+
return {
|
|
320
|
+
authenticated: true,
|
|
321
|
+
user: {
|
|
322
|
+
id: identity.userId ?? session.userId,
|
|
323
|
+
name: identity.name ?? null,
|
|
324
|
+
username: identity.username ?? null,
|
|
325
|
+
tenantId: session.tenantId,
|
|
326
|
+
},
|
|
327
|
+
tokens: {
|
|
328
|
+
accessToken: tokenResult(session.accessToken, now),
|
|
329
|
+
skypeToken: tokenResult(session.skypeToken, now),
|
|
330
|
+
chatToken: tokenResult(session.chatToken, now),
|
|
331
|
+
searchToken: tokenResult(session.searchToken, now),
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
export async function logout(paths, identity) {
|
|
336
|
+
await clearAuthentication(paths, identity);
|
|
337
|
+
}
|