Skip to content

Latest commit

 

History

History
410 lines (306 loc) · 18.5 KB

auth.md

File metadata and controls

410 lines (306 loc) · 18.5 KB

Authentication guide

This guide explains how teams authenticates to Microsoft Graph, what customers need to approve, and which auth model is suitable for commercial use.

Recommended commercial model

Use delegated Microsoft Graph auth for normal CLI usage.

Delegated auth means:

  • A real Microsoft 365 user signs in.
  • Microsoft Graph calls run on behalf of that signed-in user.
  • Teams messages sent by the CLI appear as that user.
  • The user's own Teams permissions still apply.
  • Tenant admins can grant consent to the OSO app once for the organization.

This matches Microsoft Graph's delegated access model and avoids asking every customer to create an Entra app registration just to use the CLI.

Why client credentials are not the default

Client credentials produce app-only tokens. App-only Graph access is valid for some admin and read scenarios, but it is not the right model for normal live Teams chat/channel sending.

Microsoft documents ordinary chat message sending as a delegated work/school operation with ChatMessage.Send; the application permission shown for send is Teamwork.Migrate.All, which is for migration/import scenarios, not normal live conversations. Channel replies are similar: delegated ChannelMessage.Send is the normal model and application permissions are migration-only.

The CLI now rejects app-only tokens before mutating normal Teams messages so users get a clear local error instead of a confusing Graph failure.

Use client credentials only for operations backed by Microsoft Graph application permissions that are appropriate for the target API.

OSO public client app

Delegated login defaults to OSO's multi-tenant public client app:

Client ID: fba1b5d0-fdd0-4fe2-9729-9ccdc38f9595
Authority: organizations
Redirect URI: http://localhost:8400/callback

The default lets most users start with:

teams auth login

or, for SSH/headless use:

teams auth login --device-code

The OSO app is baked into the CLI as the default delegated public client. It still requires publisher verification before broad commercial release. Publisher verification should be completed before asking external enterprise tenants to consent.

Admin consent

Generate an admin consent URL:

teams auth consent-url --tenant-id <tenant-id-or-domain> --output json

For the default app, this prints a URL like:

https://login.microsoftonline.com/<tenant>/v2.0/adminconsent?client_id=fba1b5d0-fdd0-4fe2-9729-9ccdc38f9595&scope=...&redirect_uri=...

The scope parameter is explicit so admin consent matches the CLI's default delegated Graph scopes instead of every static permission configured on the app registration.

Use a concrete tenant ID or verified tenant domain for customer onboarding. organizations is useful for sign-in discovery, but a customer admin consent link should normally target the customer's tenant explicitly.

Current delegated permissions

Default delegated login requests these Microsoft Graph permissions:

User.Read
offline_access
Team.ReadBasic.All
Channel.ReadBasic.All
ChannelMessage.Send
Chat.ReadWrite
ChatMessage.Send
ChatMessage.Read
User.ReadBasic.All
Presence.Read.All
Presence.ReadWrite

These permissions cover the current chat read/write, channel-send, team/channel discovery, user lookup, and presence reads and writes. Presence.ReadWrite is what presence set, presence set-preferred, presence status, presence clear and presence clear-preferred require; Microsoft does not mark it admin-consent required. The default does not include ChannelMessage.Read.All because Microsoft marks that delegated Graph scope as admin-consent required. Add it explicitly when a workflow needs channel message reads:

teams auth login --device-code --scopes "User.Read ChannelMessage.Read.All offline_access"

Downloading message file attachments (teams message attachments download on reference attachments stored in SharePoint/OneDrive) additionally requires the Files.Read.All delegated scope. Inline images and code snippets need no scopes beyond message reads. Add Files.Read.All to the profile's scopes and run teams auth refresh to pick it up without a new browser login.

Sending has the same split (see the scope table in docs/attachments-spec.md): message send --image sends the picture inside the message itself and needs no Files scope at all, while message send --attach must first upload the file to storage — your own OneDrive for chats (Files.ReadWrite), the team's SharePoint library for channels (Files.ReadWrite.All, typically admin consent). Note that Graph masks drives the token cannot see as 404 rather than 403, so a "not found" from --attach usually means the missing scope, not a missing file.

Automatic sharing of chat attachments also reads /me and the chat's membership, requiring User.Read and a chat-member read scope such as Chat.ReadBasic. The invite itself uses the upload's Files.ReadWrite scope. Lookup and sharing failures warn on stderr and let the upload/message continue; share the file from OneDrive by hand when automatic sharing cannot complete.

Future features may need additional consent.

Login options

Browser PKCE login:

teams auth login

The browser flow always shows the Microsoft account picker (prompt=select_account), so an existing browser session is never reused silently. This is what lets you sign a second account into another profile, for example teams --profile alt auth login.

Device-code login:

teams auth login --device-code

Custom scopes:

teams auth login --device-code --scopes "User.Read ChatMessage.Send offline_access"

Scopes can also be pinned per profile so every login for that profile requests them without repeating --scopes:

[profiles.customer]
auth_app = "byo"
client_id = "11111111-1111-1111-1111-111111111111"
tenant_id = "22222222-2222-2222-2222-222222222222"
scopes = "User.Read Chat.ReadWrite ChatMessage.Send People.Read offline_access"

The profile scopes value replaces the default delegated scope string (it is not additive); offline_access is appended when missing so refresh tokens keep working. Because it is a replacement, a profile that pins its own scopes keeps requesting exactly those when a scope is added to the default set, and has to restate the list to pick the new one up. Resolution order is --scopes or TEAMS_CLI_SCOPES, then the profile scopes field, then the default scope set. teams auth consent-url and teams auth doctor reflect the profile's resolved scopes, so admin consent links match what login will request. The default scope set remains free of admin-consent-required permissions; requesting broader scopes is an explicit opt-in for BYO app registrations. Do not use https://graph.microsoft.com/.default for delegated login — it would pull in every permission configured on the app registration instead of the explicit scope list.

Upgrading scopes without re-login

Refresh tokens are bound to the user and client, not to the scopes originally requested, so an existing session can be upgraded to newly consented permissions silently — the same mechanism MSAL uses for incremental consent:

teams auth consent-url --scopes "User.Read People.Read offline_access"  # admin grants once
teams auth refresh                                                      # silent upgrade, no browser

auth refresh redeems the stored refresh token for the resolved scope string (--scopes/TEAMS_CLI_SCOPES, then profile scopes). Without an explicit override it reuses the stored token's scope, so a plain refresh never narrows an existing session. If a requested scope has not been consented, the identity platform rejects the whole request (AADSTS65001) rather than issuing a narrower token; the CLI then prints the exact consent-url command to grant it, or fall back to teams auth login for interactive consent.

Re-login picks up a scope added to the default set only where consent for it can actually be given. The identity platform accepts scopes at authorize time that a registration does not list statically — dynamic consent — but a tenant that reserves consent to an administrator rejects the login instead, and re-running teams auth login will not change that. teams auth consent-url is the route in that case: it builds an admin-consent URL for the scopes the profile resolves to, dynamic ones included, so the permission does not have to be added to the registration first. Adding it statically is what portal consent and .default require, and it remains the tidier arrangement for a registration you own.

The practical consequence for anyone authenticating through their own registration rather than the built-in one: a newly added default scope such as Presence.ReadWrite may need an administrator to grant it before any session carries it, however many times you log in.

Customer-owned delegated app:

teams auth login --device-code \
  --client-id <customer-client-id> \
  --tenant-id <customer-tenant-id>

After a successful delegated login (browser or device code), a client ID or tenant ID given with --client-id or --tenant-id is saved to the profile's client_id and tenant_id in the config file. The next teams auth login for that profile signs in through the same registration without being told again, instead of falling back to the built-in app. Only those two values change; comments, layout and every other setting in the file are left as they were, and a symlinked config file stays a symlink. A login that fails saves nothing, and a value given again replaces the saved one. Saving is best effort: if the file cannot be written, the login still succeeds and a warning on standard error says so. The login output's saved_to_config is true when this login changed the file, and false when the values were already saved or the write failed.

TEAMS_CLI_CLIENT_ID and TEAMS_CLI_TENANT_ID still take precedence over the profile when set, but are never saved: an environment variable exported for one profile would otherwise be written into whichever profile signed in next. Each login prints the application and tenant it signs in through, and where each came from, on standard error. auth logout removes the token but keeps the saved IDs, which are settings rather than credentials; change them with another login or teams config set. The client secret is never saved.

To sign in through a registration once without saving it, pass the IDs in TEAMS_CLI_CLIENT_ID and TEAMS_CLI_TENANT_ID instead of the flags. To send a profile back to the built-in app, delete its client_id and tenant_id lines from the config file (teams config path shows where), or run teams config set profiles.<profile>.client_id null and the same for tenant_id; config set rewrites the whole file and drops its comments.

A client credentials login saves nothing. That flow has no built-in app to fall back to, and a confidential app saved to the profile would be used by the profile's next delegated login, which such an app usually cannot serve.

Client credentials for supported app-only Graph operations:

export TEAMS_CLI_CLIENT_ID=<client-id>
export TEAMS_CLI_CLIENT_SECRET=<client-secret>
export TEAMS_CLI_TENANT_ID=<tenant-id>

teams auth login --client-credentials

Pre-obtained token:

export TEAMS_CLI_ACCESS_TOKEN=<access-token>
teams user me --output json

BYO customer app

Use BYO mode when a customer requires their own Entra app registration.

Config example:

[default]
profile = "customer"
output = "json"

[profiles.customer]
auth_app = "byo"
client_id = "11111111-1111-1111-1111-111111111111"
tenant_id = "22222222-2222-2222-2222-222222222222"
auth_flow = "device-code"

Then:

teams --profile customer auth login --device-code
teams --profile customer auth doctor --output json

Instead of editing the file, a delegated login given --client-id and --tenant-id writes the profile's client_id and tenant_id once it succeeds, leaving the rest of the file as it was (see "Customer-owned delegated app" above).

BYO delegated app requirements:

  • Supported account type: usually single-tenant for locked-down enterprises.
  • Public client flows enabled.
  • Redirect URI for browser flow: http://localhost:8400/callback.
  • Delegated Graph permissions required for the features the customer wants.
  • Admin consent granted where required.

Future unattended posting

For enterprises that want agents to post without a human user session, the correct direction is a Teams app/bot mode:

  • Customer installs a Teams app/bot into the target chat, team, or channel.
  • OSO stores the bot installation/conversation reference securely.
  • Agents send proactive messages through the bot identity.
  • Audit logs clearly show an application/bot posted the message.

This is separate from the current Graph delegated CLI mode. Do not market client credentials as the solution for normal unattended Teams chat posting.

Token storage

Tokens are stored in the operating system keyring:

  • macOS: Keychain
  • Windows: Credential Manager
  • Linux: Secret Service or compatible keyring backend

The config file stores profile settings, not access tokens.

TEAMS_CLI_TOKEN_STORE=file keeps them in files instead: one per entry under the config directory's tokens/ (<profile>.token, profile-index), the directory 0700 and each file 0600 on Unix, written through a temporary file and renamed so a reader never sees a partial token. It is for a process that cannot answer a keychain dialog or has no keyring — a daemon, a server, a container. On macOS the keychain grants access to a code signature, so an unattended process running a freshly built binary is asked once per profile per build (and a build signed with a local identity is still asked, because the item's partition list names build hashes); on Linux without a Secret Service the keyring cannot store a token at all. The two stores are independent: log in once under each you use, and teams auth logout removes the token from the store it runs under.

The file store keeps the refresh token unencrypted on disk, guarded only by the directory and file permissions, so prefer the keyring where a process can use it and reach for file only where it cannot. On a shared host protect the config directory accordingly, and on Windows (no 0600) rely on the profile directory's ACL. A token file readable by the group or by others draws a warning on stderr naming it. The two stores do not share logins: after switching, run teams auth login again. Any value other than keyring, keychain or file is refused before the command runs, as invalid input (exit code 2).

On macOS and Linux each profile's keyring token is one item named <profile>:token. Windows Credential Manager limits a credential to 2560 bytes, which is smaller than a typical Microsoft Graph token bundle, so on Windows the serialized token is split across several entries: teams-cli / <profile>:token holds a small header ({"chunks": N}) and <profile>:token:0 through <profile>:token:N-1 hold the pieces. teams auth logout removes all of them.

The CLI automatically redeems the stored refresh token when an access token is expired or near expiry, then updates the keyring with the refreshed token. If no refresh token is stored, or the identity platform rejects the refresh request, commands return AUTH_TOKEN_EXPIRED and the user must run teams auth login again.

Diagnostics

Check configured auth app, consent URL, and token claims:

teams auth doctor --output json

Check whether the profile is authenticated:

teams auth status --output json

List profiles and the account each one holds:

teams auth list --output json

Each entry reports the signed-in user (principal name), tenant_id, and auth_type (delegated, app-only, or unknown), decoded from the stored token's claims, plus the stored token's expires_at. No network call is made and no refresh is attempted, so expires_at may be in the past. A profile whose token cannot be read or decoded is still listed with all four fields null, so a broken keyring entry does not hide the profile. An app-only profile has no user.

{
  "success": true,
  "data": {
    "profiles": [
      { "name": "default", "user": "a@contoso.com", "tenant_id": "...", "auth_type": "delegated", "expires_at": "..." },
      { "name": "alt", "user": "b@contoso.com", "tenant_id": "...", "auth_type": "delegated", "expires_at": "..." }
    ],
    "active": "default"
  },
  "metadata": { "request_id": "...", "timestamp": "...", "api_version": "v1.0", "duration_ms": 12 }
}

On a terminal the same information is printed as a table, with the active profile marked *:

  Profile   User             Tenant ID   Auth        Expires
* default   a@contoso.com    ...         delegated   ...
  alt       b@contoso.com    ...         delegated   ...

Note that profiles is an array of objects; a consumer that read it as an array of names needs .data.profiles[].name.

On macOS this reads one keychain item per profile, so the first run of a new binary may prompt once per profile.

Log out:

teams auth logout
teams auth logout --all

Environment variables

Variable Purpose
TEAMS_CLI_ACCESS_TOKEN Use this bearer token instead of the keyring token. When set, --profile and TEAMS_CLI_PROFILE do not affect which token is used.
TEAMS_CLI_PROFILE Named credential profile. Precedence: --profile, then TEAMS_CLI_PROFILE, then the config's default.profile, then default.
TEAMS_CLI_CLIENT_ID Entra app client ID.
TEAMS_CLI_CLIENT_SECRET Client secret for client credentials flow.
TEAMS_CLI_TENANT_ID Tenant ID or tenant domain.
TEAMS_CLI_SCOPES Delegated OAuth scopes for login. Same precedence as --scopes; ignored by client credentials login.
TEAMS_CLI_TOKEN_STORE keyring (default) or file: where tokens are kept. file writes them under the config directory's tokens/, for unattended processes and hosts without a keyring.
TEAMS_CLI_DISABLE_KEYRING Test-only escape hatch used by CLI tests to avoid real OS keyring access.

Microsoft references