Document OAuth login methods and unattended agent patterns#242
Conversation
Device code is the CLI's only built-in sign-in flow. Clarify how it fits unattended agents (one-time interactive login, then non-interactive refresh) and document the standard no-human alternatives (app-only client credentials, workload identity federation) with their trade-offs, noting they are not yet implemented in the CLI. Add operational guidance for driving `login` from a wrapper: it is a synchronous foreground poll that must stay alive until sign-in completes, verification should use whoami + verify-token, and the refresh lock auto-heals (no manual lock cleanup). Advise against scripting the device-code browser UI to fake unattended login. Updates docs/AUTHENTICATION.md and the bundled skills/m365-agent-cli/SKILL.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
Drop the alternative login-method documentation (app-only client credentials, workload identity federation); device code stays the only supported sign-in flow for now. The device-code operational notes (foreground poll, verification, refresh-lock auto-heal) remain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
… example Make it easier to automate the device-code sign-in for unattended agents, while keeping device code as the only auth flow. - login: add `--json` to emit newline-delimited JSON events (device_code, authenticated, complete, error) on stdout so a wrapper can capture the user code / verification URL instead of scraping log text; human-readable text moves to stderr. Non-interactive (errors instead of prompting when EWS_CLIENT_ID is missing). Default (non-json) output is unchanged. - docs: add UNATTENDED_LOGIN.md — how to complete the interactive device-code step with a headless browser + TOTP, the security tradeoffs, the Microsoft page sequence, the pitfalls, and hygiene rules. Linked from AUTHENTICATION.md and the bundled skill. - examples/unattended-login: adaptable, secret-free reference scripts (Playwright automation + an orchestration wrapper) to copy and adapt. - biome: exclude examples/ (reference code users copy, not repo source). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
CodeQL flagged logging the password / TOTP-seed lengths in the unattended-login example as clear-text logging of sensitive data. Log a generic status line instead — never reference a secret (or its length) in a log sink. Align the shell wrapper and the guide's hygiene note to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
Explain how to obtain the raw base32 TOTP seed the automation needs, which a
normal phone (QR) setup hides: reveal it during self-service authenticator
registration ("Can't scan image?"), or provision your own as an admin OATH
token. Add the tenant authentication-methods / Conditional Access
prerequisites and how to generate and verify codes (SHA-1 / 6 digits / 30s).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
enroll-totp.mjs signs in with the account password, drives the Microsoft
Security info wizard down the "I want to use a different authenticator app"
branch, scrapes the base32 seed off the "Can't scan image?" screen, activates
the method with a generated code, and prints {"totp_secret","account_name"} on
stdout for the caller to store in a vault. Diagnostics stay on stderr and the
secret is never logged.
Document it in UNATTENDED_LOGIN.md ("Automated first-time TOTP enrollment"):
the two gates that must be open (password sign-in works interactively but not
via ROPC, which security defaults block; and no "require MFA to register
security info" Conditional Access policy, which would force a TAP), that this
captures Microsoft's generated seed rather than injecting one you choose, and
that the SPA selectors are a starting point that will drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
enroll.sh wraps enroll-totp.mjs into a hands-off first-time bootstrap: fetch email + password from your secret store, run the enrollment, parse and store the returned base32 seed (piped on stdin so it never hits argv), then verify end-to-end by running refresh-token.sh — which re-reads the stored seed and completes a real device-code sign-in, proving both that MFA works and that the seed round-tripped the store. Verification is skipped when the CLI isn't on the host or ENROLL_VERIFY=0. Mirrors refresh-token.sh's fetch_secret placeholder and no-jq node-inline parsing; document it in the README and UNATTENDED_LOGIN.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
…rences enroll-totp.mjs now accepts a Temporary Access Pass (M365_TAP) as an alternative to a password (exactly one required), redeeming it in the TAP field, and adds an explicit "confirm signed in" gate that fails fast on an expired/invalid credential before touching the wizard. A TAP satisfies the "require MFA to register" policy, so this covers hardened tenants and accounts that already have MFA. enroll.sh prefers a TAP (m365-tap) over a password for the enrollment sign-in. The verify step still needs a first-factor password from the vault, since TOTP is only a second factor and a TAP can't set a password; it's skipped with a clear note when no password is present. Reference the MFA/TOTP enrollment docs from SKILL.md and AUTHENTICATION.md, and document the TAP path plus the password-for-refreshes caveat across UNATTENDED_LOGIN.md and the example README. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
…thentication section The pointer to UNATTENDED_LOGIN.md and the enrollment examples previously lived only in a long agent-tips bullet. Add a concise, discoverable pointer in the top-level "Authentication and profiles" section — where an agent looks first — covering headless device-code login and bootstrapping a software TOTP (optionally via a Temporary Access Pass on tenants that require MFA to register), with the reminder that a first-factor password must still exist for refreshes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
Bump package.json to the current calendar version and sync the skill metadata version (npm run sync-skill). Add a CHANGELOG entry for this line covering the `login --json` unattended mode and the unattended-login guide + reference scripts (device-login, refresh-token, TOTP/TAP enrollment). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
…amples refresh-token-lock.ts: - Release the lock only if we still own it (unique per-acquisition owner token written as a third line; verified before unlink). Previously the finally block unlinked by path unconditionally, so a slow holder whose lock had been reclaimed as stale would delete a legitimate successor's lock and break mutual exclusion — the exact shared-RT double-refresh race the lock prevents. Adds a regression test. - Raise DEFAULT_MAX_WAIT_MS (60s -> 180s) so it exceeds staleMs; a waiter can now reclaim a stale lock instead of hard-failing sibling processes before the lock is even eligible to be considered stale. auth.ts / graph-auth.ts: - Drop the dead `getUnifiedRefreshTokenFromEnv() ?? envRefreshToken`; env is read once at entry and does not change mid-run, so both operands were identical. examples/unattended-login/refresh-token.sh: - Fail fast on an emitted `error` event instead of burning the full 30s waiting for a device_code that will never arrive (e.g. missing EWS_CLIENT_ID). - Make the post-login whoami/verify-token best-effort so a transient failure under `set -e` no longer flips an already-persisted successful login to FAILURE. examples/unattended-login/device-login.mjs: - Require M365_USER_CODE (unless M365_VERIFICATION_URI_COMPLETE is set) up front, instead of silently filling an empty code and failing opaquely later. Full suite: 864 pass, 0 fail; typecheck and biome clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
… dedup) login.ts: - emitEvent now writes JSON events synchronously to fd 1 (writeSync) instead of process.stdout.write. On POSIX a piped stdout is async/buffered, so an error event emitted immediately before process.exit(1) could be truncated and a piping consumer would never see it. Verified: `login --json` with no client id, piped, now reliably delivers the error event. Events also stay correctly ordered. - Dedup the `humanLog` closure (previously defined identically in both performDeviceCodeFlow and the command action) into one makeHumanLog factory. refresh-token-lock.ts: - Clamp maxWaitMs up to at least staleMs (Math.max) so the "wait long enough to reclaim a stale lock" invariant holds even for a custom staleMs, not just the defaults. Full suite: 864 pass, 0 fail; typecheck and biome clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
…ing --json flush The previous fix switched emitEvent to fs.writeSync(1, ...) to guarantee JSON error events flush before process.exit on a POSIX pipe. CodeQL flags fs.writeSync as a file-system sink fed by the OAuth network response (alert #85: "network data written to file system"), which process.stdout.write is not. Revert emitEvent to process.stdout.write (CodeQL-clean) and add an async fatalJson() helper for the terminal error paths: it awaits the stdout write callback (so the event flushes before exit) with a fallback timer in case the callback never fires (EPIPE). Non-fatal events (device_code / authenticated / complete) flush naturally as the flow continues, so they keep using emitEvent. Verified: `login --json` with no client id, piped, still reliably delivers the error event. Full suite 864 pass / 0 fail; typecheck and biome clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ
|
Coverage after merging claude/agent-login-flows-dxfk01 into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||
Summary
This PR adds comprehensive documentation about authentication methods for
m365-agent-cli, with a focus on clarifying the distinction between interactive device-code flow (the only built-in method) and standard OAuth alternatives for fully unattended scenarios (app-only credentials and workload identity federation).Key Changes
New "Login methods" section in
docs/AUTHENTICATION.md:New "Driving
loginfrom a script or agent" section indocs/AUTHENTICATION.md:loginis a synchronous foreground poll that must stay alive until sign-in completes or device code expires (~15 min)whoamiandverify-token --capabilitiesrather than relying on wrapper output.refresh-{identity}.lock(stale lock cleanup after 120s or dead PID)Updated
skills/m365-agent-cli/SKILL.md:loginalive, verify with CLI commands, not wrapper success messagesNotable Details
https://claude.ai/code/session_01XDLvytPEefgPnLm2vk7FRQ