Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,25 @@
### Added

- `--redirect-uri`, `TEAMS_CLI_REDIRECT_URI` and the profile's `redirect_uri` select the browser login's redirect URI for `auth login`, `auth consent-url` and `auth doctor`. The login listens on the URI's host and port (both loopback addresses for `localhost`) for a request to the URI's path carrying the login's `state`; the flag is saved to the profile with the IDs. A redirect URI without a port gets a free port from the OS at each login, as MSAL does; the identity platform allows this because it ignores the port of a localhost redirect URI, so `--redirect-uri http://localhost` works for an app registered that way.
- `teams subscribe create --lifecycle-url URL` sets the subscription's `lifecycleNotificationUrl`, which Graph requires for Teams subscriptions longer than one hour. Graph validates that URL too, so `teams listen` answers validation and notifications on `/lifecycle` as well as `/` and `/webhook`, and one tunnel serves both.
- `teams listen --client-state SECRET` (or `TEAMS_CLI_CLIENT_STATE`) drops notifications whose `clientState` is missing or different, with a warning on stderr that does not include either value. Without it, the listener warns at startup that notifications are not authenticated.
- `teams listen --bind ADDR` chooses the listening address, and `--port 0` picks a free port; the startup line names the address actually bound.
- Each `teams listen` line now carries every field Graph sent (`subscriptionExpirationDateTime`, `lifecycleEvent`, `encryptedContent` and so on) and `kind`: `lifecycle` when `lifecycleEvent` is present, else `change`. The README's "Running behind a relay" says what a relay should POST and which statuses to retry.

### Changed

- **`teams listen` binds `127.0.0.1` by default instead of `0.0.0.0`.** Pass `--bind 0.0.0.0` to accept connections from other hosts.
- `teams listen` answers notifications with 202 once they are queued for stdout, instead of 200 after printing, so a slow stdout reader no longer holds the HTTP response; when the queue (8 MiB) is full it answers 503 so the sender retries. Validation requests are answered whatever the queue state. On Ctrl+C or SIGTERM it stops admitting notifications (503) and writes out every one it already acknowledged before exiting; a second signal abandons them, and any acknowledged notification left unwritten, or lost to a closed stdout, is counted on stderr with exit code 1 (previously a closed stdout exited 0).
- `teams listen` limits bodies to 1 MiB (413), allows 10 seconds for headers and for the body (408), and serves at most 64 connections at once.
- `teams listen` no longer prints `clientState`.
- The default redirect URI is now `http://localhost/callback` on a free port instead of `http://localhost:8400/callback`, so a busy port 8400 no longer blocks login. App registrations with `http://localhost:8400/callback` keep working because the port is ignored.
- Updated Rust dependencies (`tokio` 1.54.1, `serde_json` 1.0.154, `uuid` 1.28.0, `webbrowser` 1.2.5) and the CI and release workflows' `Swatinem/rust-cache` pin to v2.9.3. `rust-version` in `Cargo.toml` is now 1.89, the oldest Rust the dependencies build with; it said 1.75.
- Updated `sha2` to 0.11.1 (it computes the PKCE code challenge for browser login). The challenge is now unit-tested against the RFC 7636 example, so a hashing or encoding change cannot slip through as failed logins.

### Fixed

- Log lines on stderr are coloured only when stderr is a terminal and `NO_COLOR` is unset, so `teams listen 2>listen.log` and other redirected logs no longer contain ANSI escape codes. `--no-color` still turns colour off.
- `teams listen` on a port already in use exits with code 10 (`CONFIG_ERROR`), naming the address and the OS error, instead of reporting an authentication failure (exit code 3).
- The browser login keeps serving its loopback redirect URI until the callback arrives. It served one connection, so a browser's speculative connection or a second request could leave it waiting forever or failing with "Failed to receive auth code from callback". A redirect carrying the identity platform's `error` (a declined consent, say) now fails the login at once with `error` and `error_description` (exit code 3) instead of that generic message, requests to other paths or with another `state` are ignored, and the login gives up after 10 minutes with no callback.
- Updated `rustls-webpki` to 0.103.16 and `webpki-roots` to 1.0.11 for RUSTSEC-2026-0382 and RUSTSEC-2026-0381, under which certificates issued by distrusted intermediates could still be accepted. The `tokio` update also clears RUSTSEC-2026-0386 (unsound `AsyncFd` constructors).

Expand Down
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,17 +85,20 @@ CLI flags > env vars (TEAMS_CLI_CLIENT_ID, TEAMS_CLI_CLIENT_SECRET, TEAMS_CLI_TE
- `$top` parameter for page size control

### Webhook Listener
- `teams listen --port 8080` starts a hyper HTTP server
- Handles Graph subscription validation (echoes `?validationToken`)
- Outputs change notifications as NDJSON to stdout
- Multi-connection, graceful Ctrl+C shutdown
- `teams listen --port 8080` starts a hyper HTTP server on 127.0.0.1 (`--bind` to change)
- Handles Graph subscription validation (echoes `?validationToken`) on `/`, `/webhook` and `/lifecycle`
- Outputs change notifications as NDJSON to stdout via a bounded queue and writer thread: 202 once queued, 503 when full
- `--client-state` / `TEAMS_CLI_CLIENT_STATE` drops notifications with another or no `clientState`; output strips `clientState` and adds `kind`
- Limits: 1 MiB body (413), 10s header and body deadlines, 64 connections
- Ctrl+C/SIGTERM: stop admitting (503), write out everything acknowledged; a second signal abandons it; unwritten acknowledged lines → count on stderr, exit 1
- Requires HTTPS via reverse proxy (ngrok) for production use

## Environment Variables
- `TEAMS_CLI_CLIENT_ID` — Azure AD application (client) ID
- `TEAMS_CLI_CLIENT_SECRET` — Azure AD client secret
- `TEAMS_CLI_TENANT_ID` — Azure AD tenant ID
- `TEAMS_CLI_ACCESS_TOKEN` — Pre-obtained access token
- `TEAMS_CLI_CLIENT_STATE` — clientState `teams listen` requires on notifications
- `TEAMS_CLI_TOKEN_STORE` — `keyring` (default) or `file` (0600 files under the config dir's `tokens/`)
- `RUST_LOG` — Tracing filter level

Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ teams subscribe create \
--resource "/teams/{team-id}/channels/{channel-id}/messages" \
--change-type created,updated \
--webhook-url https://your-endpoint/webhook
# Over one hour, Graph also needs --lifecycle-url https://your-endpoint/lifecycle
teams subscribe list
teams subscribe renew <subscription-id> [--expiration <datetime>]
teams subscribe delete <subscription-id>
Expand All @@ -625,15 +626,43 @@ teams listen --port 8080
teams listen --port 8080 | jq '.changeType, .resource'
```

The listener binds `127.0.0.1` unless `--bind` says otherwise (`--bind 0.0.0.0` for all interfaces). It answers Graph's validation request and accepts notifications on `/`, `/webhook` and `/lifecycle`, so one tunnel can serve both `--webhook-url` and `--lifecycle-url` (Graph validates both when the subscription is created). Each line is one notification with every field Graph sent except `clientState`, plus `"kind": "change"` or, for lifecycle events (`lifecycleEvent`), `"kind": "lifecycle"`.

Pass the subscription's `--client-state` to `listen` too (or set `TEAMS_CLI_CLIENT_STATE`) and notifications without that `clientState` are dropped with a warning on stderr. Without it, anyone who can reach the port can inject lines, and the listener says so at startup.

**Note:** Microsoft Graph requires HTTPS for webhook URLs. Use [ngrok](https://ngrok.com) or similar:

```bash
export TEAMS_CLI_CLIENT_STATE="$(openssl rand -hex 16)"
ngrok http 8080
teams subscribe create --resource "/teams/all/messages" \
--change-type created --webhook-url https://xxxx.ngrok.io/webhook
--change-type created --webhook-url https://xxxx.ngrok.io/webhook \
--client-state "$TEAMS_CLI_CLIENT_STATE"
teams listen --port 8080
```

#### Running behind a relay

A relay can forward notifications to `teams listen` instead of Graph calling it directly:

- POST the raw Graph body, `{"value":[...]}`, to `/`, `/webhook` or `/lifecycle`.
- Graph's validation request (`?validationToken=`) needs a synchronous reply within 10 seconds, so answer it at the public edge (echo the decoded token as `text/plain`) rather than forwarding it.
- Bind to the interface the relay uses with `--bind`, and set `--client-state` so forged posts are dropped.

Responses:

| Status | Meaning | Relay should |
|--------|---------|--------------|
| 202 | Queued for stdout, or dropped for a wrong `clientState` | Not retry |
| 400 | Body is not a notification collection | Not retry |
| 408 | Body not received within 10 seconds | Retry |
| 413 | Body over 1 MiB | Not retry |
| 503 | Output queue full (stdout reader is behind), or shutting down | Retry with backoff |

At most 64 connections are served at once; others wait. Headers must arrive within 10 seconds.

On Ctrl+C or SIGTERM the listener stops accepting, answers 503, and writes out every notification it already answered 202, however long stdout takes. A second signal abandons the rest. If any acknowledged notification is not written (second signal, or stdout closed), it prints how many on stderr and exits 1.

### User Lookup

```bash
Expand Down Expand Up @@ -731,6 +760,7 @@ auth_flow = "device-code"
| `TEAMS_CLI_PROFILE` | Named credential profile (overridden by `--profile`; an explicit value beats the config's `default.profile`) |
| `TEAMS_CLI_SCOPES` | Delegated OAuth scopes for login (same precedence as `--scopes`; ignored by client credentials) |
| `TEAMS_CLI_ACCESS_TOKEN` | Pre-obtained Microsoft Graph access token (skips login entirely; `--profile`/`TEAMS_CLI_PROFILE` do not affect which token is used) |
| `TEAMS_CLI_CLIENT_STATE` | Secret `teams listen` requires in each notification's `clientState` (same as `--client-state`) |
| `RUST_LOG` | Tracing filter (e.g., `debug`, `teams=trace`) |

## Verbosity
Expand Down
6 changes: 3 additions & 3 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,14 +282,14 @@ If `file download` runs without `--path`, raw bytes go to stdout. Use `--path` i
## Subscriptions and listener

```bash
teams subscribe create --resource RESOURCE --change-type created,updated,deleted --webhook-url URL [--expiration ISO8601] [--client-state STATE]
teams subscribe create --resource RESOURCE --change-type created,updated,deleted --webhook-url URL [--expiration ISO8601] [--client-state STATE] [--lifecycle-url URL]
teams subscribe list
teams subscribe renew SUBSCRIPTION_ID [--expiration ISO8601]
teams subscribe delete SUBSCRIPTION_ID
teams listen [--port PORT]
teams listen [--port PORT] [--bind ADDR] [--client-state SECRET]
```

Microsoft Graph requires an HTTPS notification URL. `teams listen` is a local HTTP listener for testing behind a tunnel or reverse proxy.
Microsoft Graph requires an HTTPS notification URL. `teams listen` is a local HTTP listener for testing behind a tunnel or reverse proxy. It binds `127.0.0.1` unless `--bind` says otherwise. With `--client-state` (or `TEAMS_CLI_CLIENT_STATE`), notifications without that `clientState` are dropped. Output lines omit `clientState` and add `kind` (`change` or `lifecycle`). For relays and status codes, see "Running behind a relay" in the README.

## Completions

Expand Down
44 changes: 41 additions & 3 deletions docs/man/teams.1
Original file line number Diff line number Diff line change
Expand Up @@ -375,16 +375,48 @@ for a destination file because global
is reserved for json, human, and plain output formats.
.SH SUBSCRIPTIONS AND LISTENER
.nf
teams subscribe create --resource RESOURCE --change-type TYPES --webhook-url URL [--expiration ISO8601] [--client-state STATE]
teams subscribe create --resource RESOURCE --change-type TYPES --webhook-url URL [--expiration ISO8601] [--client-state STATE] [--lifecycle-url URL]
teams subscribe list
teams subscribe renew SUBSCRIPTION_ID [--expiration ISO8601]
teams subscribe delete SUBSCRIPTION_ID
teams listen [--port PORT]
teams listen [--port PORT] [--bind ADDR] [--client-state SECRET]
.fi
.PP
Microsoft Graph requires HTTPS notification URLs. The local listener is plain
HTTP and is intended to sit behind a reverse proxy such as ngrok for testing.
It emits change notifications as newline-delimited JSON.
It emits change notifications as newline-delimited JSON: every field Graph
sent except
.B clientState,
plus
.B kind
(\fBlifecycle\fR when
.B lifecycleEvent
is present, else \fBchange\fR).
.PP
The listener binds 127.0.0.1 unless
.B --bind
names another address, such as 0.0.0.0.
With
.B --client-state
(or
.B TEAMS_CLI_CLIENT_STATE),
notifications without that clientState are dropped with a warning on stderr;
without it, the listener warns that notifications are not authenticated.
.PP
Behind a relay, POST the raw Graph body
.B {"value":[...]}
to /, /webhook or /lifecycle, and answer Graph's validationToken request
at the public edge. Responses: 202 queued, or dropped for a wrong clientState
(do not retry); 400 malformed (do not retry); 408 body not received within 10
seconds; 413 body over 1 MiB (do not retry); 503 output queue full because
the stdout reader is behind, or shutting down (retry with backoff). At most 64
connections are served at once, and headers must arrive within 10 seconds.
.PP
On Ctrl+C or SIGTERM the listener stops accepting, answers 503, and writes out
every notification it already answered 202. A second signal abandons the rest.
If any acknowledged notification is not written, the count goes to stderr and
the exit code is 1.
A port already in use exits with code 10.
.SH COMPLETIONS
.nf
teams completions bash
Expand All @@ -406,6 +438,12 @@ Prefer it to
.B --client-secret,
whose value shows in process listings and shell history.
.TP
.B TEAMS_CLI_CLIENT_STATE
Secret
.B teams listen
requires in each notification's clientState. Same as
.B --client-state.
.TP
.B TEAMS_CLI_TENANT_ID
Azure AD tenant ID.
.TP
Expand Down
7 changes: 5 additions & 2 deletions src/cli/listen.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use std::net::IpAddr;

use crate::error::Result;

pub async fn run(port: u16) -> Result<()> {
crate::listen::run_listener(port).await
pub async fn run(bind: IpAddr, port: u16, client_state: Option<String>) -> Result<()> {
let client_state = client_state.filter(|s| !s.is_empty());
crate::listen::run_listener(bind, port, client_state).await
}
15 changes: 13 additions & 2 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,16 @@ pub enum Commands {
},
/// Start a webhook listener for change notifications
Listen {
/// Port to listen on
/// Port to listen on (0 picks a free port)
#[arg(long, default_value = "8080")]
port: u16,
/// Address to bind. Use 0.0.0.0 to accept connections from other hosts
#[arg(long, default_value = "127.0.0.1")]
bind: std::net::IpAddr,
/// Print only notifications carrying this clientState. Prefer the
/// environment variable: a flag value shows in process listings
#[arg(long, env = "TEAMS_CLI_CLIENT_STATE", hide_env_values = true)]
client_state: Option<String>,
},
}

Expand Down Expand Up @@ -234,7 +241,11 @@ pub async fn run(cli: Cli, config: &ConfigFile) -> Result<()> {
Commands::Subscribe { command } => {
subscribe::run(command, &runtime_config, &profile, format, &pagination).await
}
Commands::Listen { port } => listen::run(port).await,
Commands::Listen {
port,
bind,
client_state,
} => listen::run(bind, port, client_state).await,
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/cli/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pub enum SubscribeCommand {
/// Client state string for validation
#[arg(long)]
client_state: Option<String>,
/// Lifecycle notification URL; Graph requires it for subscriptions over one hour
#[arg(long)]
lifecycle_url: Option<String>,
},
/// List active subscriptions
List,
Expand Down Expand Up @@ -62,6 +65,7 @@ pub async fn run(
webhook_url,
expiration,
client_state,
lifecycle_url,
} => {
let start = Instant::now();
let expiration_date_time = expiration.unwrap_or_else(|| {
Expand All @@ -74,6 +78,7 @@ pub async fn run(
resource,
expiration_date_time,
client_state,
lifecycle_notification_url: lifecycle_url,
};
let sub = api::subscriptions::create_subscription(&client, &req).await?;
output::print_success(format, &sub, start);
Expand Down
Loading
Loading