Skip to content

fix(auth): align device-code timeout to the ~15-min code lifetime + document headless/sub-agent sign-in - #38

Merged
Gregory Joseph (gnjoseph) merged 2 commits into
feat/spe-mcp-serverfrom
agents/pr3-wi13-devicecode
Jul 9, 2026
Merged

fix(auth): align device-code timeout to the ~15-min code lifetime + document headless/sub-agent sign-in#38
Gregory Joseph (gnjoseph) merged 2 commits into
feat/spe-mcp-serverfrom
agents/pr3-wi13-devicecode

Conversation

@gnjoseph

Copy link
Copy Markdown
Collaborator

Problem (PR #3 review)

The device-code sign-in cancel was hardcoded:

const DEVICE_CODE_TIMEOUT_MS = process.stderr.isTTY ? 120_000 : 600_000; // 2 min TTY / 10 min headless

The 10-min (600s) headless bound is shorter than the ~15-min (900s) Azure AD device-code lifetime, so the client prematurely cancelled a sign-in that was still valid — the user could still have completed it. The 2-min TTY value was likewise arbitrarily short. Two adjacent review questions also asked for the orchestrator/sub-agent behavior and the interactive-vs-headless rationale to be documented.

Fix

src/auth.ts — timeout now derived from the real code lifetime:

  • New DEVICE_CODE_LIFETIME_SECONDS = 900 (~15 min, the official AAD device-code lifetime); DEVICE_CODE_TIMEOUT_MS is derived from it (no longer TTY-dependent, never below the lifetime).
  • New pure helper deviceCodeCancelDelayMs(expiresIn?) → converts the STS-reported DeviceCodeResponse.expiresIn (seconds) to ms, defaulting to the ~15-min lifetime when not yet known.
  • The request now sets the native DeviceCodeRequest.timeout to the code lifetime. Per MSAL, "the device code expiration window will always take precedence over this set period", so it caps the wait without cancelling a still-valid code.
  • The JS safety-net timer is re-armed inside deviceCodeCallback using response.expiresIn, so it can never fire before the code actually expires.
  • Existing fail-fast-when-invisible behavior is unchanged (device code only offered on a TTY; headless without visibility fails fast with an actionable error — no invisible hang). Refreshed the stale "hang for up to 10 minutes" comment.

README.md — new "Headless & orchestrator / sub-agent sign-in" section:

  • Interactive defaults (on locally / off in CI + Linux-without-DISPLAY) and the SPE_INTERACTIVE=1 / SPE_NON_INTERACTIVE=1 overrides.
  • Why device-code prompts print to stderr (not visible to a calling agent over stdio) and therefore fail fast instead of hanging; the recommended pre-authenticate + restart pattern for headless/agent setups.
  • The ~15-min device-code timeout that never cancels early.

Why interactive is kept (not removed)

A local SPE app developer benefits from a one-time browser consent (token cached live, no restart); automation gets the opposite default (off). The existing resolution — interactive by default locally, off in headless, explicit overrides — is reasonable, so this PR documents it rather than removing interactive support.

Tests

Added a device-code sign-in timeout suite in src/auth.test.ts:

  • Cancel horizon is >= the code lifetime and is not the old 600_000 / 120_000.
  • expiresIn drives the derivation (900 → 900_000, 1200 → 1_200_000); invalid/unknown falls back to the ~15-min default.
  • Fake-timers integration: with expiresIn = 900, the request's native timeout is 900, cancel is still false past the old 600s point and at 899s, and only flips true at the code lifetime.

Validation

  • npm run lint ✅ · npm run typecheck ✅ · npm run build
  • npm test ✅ — 717 passed | 3 skipped (baseline 713/3; +4 new).

Scope / safety

Only the timeout derivation + comments + docs changed. Token/account-selection and guest/B2B logic are untouched. Behavior stays non-blocking / fail-fast where it already was.

Ref: PR #3 review.

Greg Joseph and others added 2 commits July 9, 2026 08:51
…ocument headless/sub-agent sign-in

The device-code cancel was hardcoded to 2 min (TTY) / 10 min (headless).
The 10-min (600s) headless bound is SHORTER than the ~15-min (900s) Azure AD
device-code lifetime, so it prematurely cancelled a sign-in that was still
valid. MSAL already stops polling at code expiry, so our client timer only
ever needs to be a safety net at that same horizon - never below it.

Fix (src/auth.ts):
- Replace the fixed, TTY-dependent DEVICE_CODE_TIMEOUT_MS with a bound DERIVED
  from the device code's real lifetime: DEVICE_CODE_LIFETIME_SECONDS = 900 and
  a pure deviceCodeCancelDelayMs(expiresIn) helper (seconds -> ms, defaulting
  to the ~15-min lifetime when expiresIn is unknown).
- Set the native DeviceCodeRequest.timeout to the code lifetime; per MSAL "the
  device code expiration window will always take precedence over this set
  period", so it caps the wait without cancelling a still-valid code.
- Re-arm the JS safety-net timer inside deviceCodeCallback using the
  STS-reported response.expiresIn, so it can never fire before the code expires.
- Keep the existing fail-fast-when-invisible behavior; refresh the stale
  "hang for up to 10 minutes" comment.

Docs (README.md): new "Headless & orchestrator / sub-agent sign-in" section
covering interactive defaults + SPE_INTERACTIVE / SPE_NON_INTERACTIVE
overrides, why device-code prompts (stderr) are not visible to a calling agent
and therefore fail fast instead of hanging, the pre-auth + restart pattern,
and the ~15-min device-code timeout.

Tests (src/auth.test.ts): assert the cancel horizon is >= the code lifetime and
never the old 600s/120s, that expiresIn drives the derivation, and (fake
timers) that a still-valid code is not cancelled before it expires.

Token/account-selection and guest/B2B logic are untouched.

Ref: PR #3 review.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Defense-in-depth from PR #3 review: bound the expiresIn-derived cancel delay to
4x the ~15-min AAD code lifetime so an absurd expiresIn can't exceed Node's max
setTimeout delay (~24.85 days) and get clamped to 1ms (near-instant cancel).
AAD never issues such values and MSAL's native timeout stays authoritative.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@gnjoseph
Gregory Joseph (gnjoseph) merged commit 9113292 into feat/spe-mcp-server Jul 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant