Skip to content

feat: browser-based OAuth login + smoke-test bug fixes + codebase cleanup#50

Merged
chenliuyun merged 72 commits into
mainfrom
feat/browser-login
May 22, 2026
Merged

feat: browser-based OAuth login + smoke-test bug fixes + codebase cleanup#50
chenliuyun merged 72 commits into
mainfrom
feat/browser-login

Conversation

@chenliuyun
Copy link
Copy Markdown
Collaborator

@chenliuyun chenliuyun commented May 21, 2026

Summary

  • Browser login: Replace Cognito with sp.oauth.switchbot.net OAuth flow — switchbot auth login opens a hosted login page, handles code exchange, and saves credentials to the OS keychain. Includes CSRF protection, fixed callback port 53245, AES-128-CBC token decryption, countdown timer, and security hardening (XSS guard, body-size cap, double-close guard, security headers).
  • switchbot reset command: New command to wipe all local state (credentials, cache, config).
  • UX: No-credentials error reformatted as a numbered choice list with switchbot auth login suggestion.
  • Smoke-test bug fixes (v3.6.3): 10 bugs fixed across MCP outputSchema, CLI exit codes, quota dry-run, device --fields projection, history aggregation timestamp, cache fetchedAt accuracy, and --cache flag documentation.
  • MCP test coverage: Added outputSchema boundary tests, mcpError format-contract test, and shared parseErrorText helper.
  • Devices list: family and room columns now shown by default (no --wide needed).
  • Codebase cleanup: Removed dead web/login.html, orphaned local-login-server.ts, completed plan/spec docs, stale design docs, and contrib/systemd.

Code-review fixes (added after initial review)

  • auth login --config [P1]: When --config <path> is set, credentials are now saved directly to that config file (via saveConfig()) instead of the OS keychain or ~/.switchbot/config.json. Prevents silent credential mismatch in portable setups.
  • reset --config [P1]: reset now resolves devices.json, status.json, device-meta.json, and the cache dir relative to the --config directory instead of always using ~/.switchbot. quota.json, device-history/, and audit.log remain global (their writers hardcode ~/.switchbot).
  • reset --json exit code [P2]: JSON mode now exits 1 when any reset step fails (previously always exited 0).
  • devices list --json --fields cloud [P2]: cloud field now emits boolean/null instead of 'true'/'false'/'—' strings when using the --fields projection path.
  • Auth login hint --profile [P3]: The no-credentials error message option 1 (switchbot auth login) now carries --profile <name> when a named profile is active, matching option 2 behavior.
  • History aggregation open-ended range [P1]: Date.now() is now called once before the for await loop instead of per-record, preventing multiple spurious buckets when --to is omitted.

Test plan

  • switchbot auth login — opens sp.oauth.switchbot.net in browser, completes OAuth, saves credentials
  • switchbot auth login --no-open — prints login URL with correct client_id, redirect_uri=http://127.0.0.1:53245/callback, scope=api_login
  • switchbot --config ./portable.json auth login --no-open — credentials saved to portable.json, not to keychain
  • switchbot devices list — shows family and room columns by default
  • switchbot devices list --json --fields cloudcloud is true/false (boolean), not "true"/"false" (string)
  • switchbot reset — wipes local state
  • switchbot --config ./portable.json reset --yes — wipes files adjacent to portable.json
  • npm test — all tests pass (2645/2650; 5 pre-existing doctor.test.ts failures unrelated to this branch)
  • npm run build — clean build, no TypeScript errors

chenliuyun and others added 30 commits May 20, 2026 22:00
Add switchbot auth login command that starts a local HTTP server,
opens a custom SwitchBot-styled login page in the browser, and
exchanges credentials via the SwitchBot consumer account API.

Auth flow:
1. POST account.api.switchbot.net/account/api/v2/user/login → access_token
2. POST /account/api/v1/user/userinfo → botRegion (region-aware routing)
3. POST wonderlabs.{region}.api.switchbot.net/wonder/openapi/openUser/token
   { operation: "get", version: 2 } → openToken + secretKey

New files:
- web/login.html: dark-theme standalone login page (email/password +
  Google/Amazon/Apple social OAuth buttons)
- src/auth/local-login-server.ts: local HTTP server serving login page,
  proxying /auth/email, and handling /callback OAuth redirect
- src/auth/browser-login.ts: entry point, also retains --direct fallback
- src/auth/constants.ts: API base URLs and endpoint paths
- src/auth/oauth-callback.ts, pkce.ts, token-exchange.ts: OAuth helpers

Also:
- scripts/copy-assets.mjs: copy web/ into dist/web/ on build
- src/commands/capabilities.ts: register auth login metadata
- tests/commands/capabilities-meta.test.ts: add auth login to coverage list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The /openapi/openUser/token v2 endpoint returns token and secretKey
encrypted with AES-128-CBC (hex-encoded). Decrypting with utf8 output
produced garbled 46-char strings unusable for HMAC-SHA256 signing;
the correct output encoding is hex, yielding the expected 96-char token
and 32-char secret.

Also adds TOKEN_AES_KEY/TOKEN_AES_IV constants, removes the intermediate
botRegion userinfo probe (not needed for this endpoint), and moves `open`
to runtime dependencies so installed packages can launch the browser.

auth login output now shows both token and secret lengths for verification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clears all local account data after an interactive y/N confirmation:
credentials (keychain + config.json + profiles/), device cache,
devices list cache, quota counter, device history, device metadata,
and audit log.

Options:
  -y / --yes            skip confirmation (for scripting)
  --keep-credentials    preserve credentials, clear data files only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Share SECURITY_HEADERS from utils.ts across local-login-server and oauth-callback
- Add COGNITO_DOMAIN, KNOWN_BOT_REGIONS, DEFAULT_BOT_REGION constants
- Add botRegion lookup via /userinfo in handleEmailLogin
- Add fetchCredentials botRegion param with dynamic Wonder API URL
- Merge duplicate crypto imports in local-login-server
- Remove unused ItemKey type alias in reset.ts
- Fix test assertions for botRegion handling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ent header

- Remove Cognito dependencies (OAUTH_AUTHORIZE_URL, OAUTH_TOKEN_URL, COGNITO_DOMAIN)
- Open sp.oauth.switchbot.net/login as the hosted SwitchBot OAuth page
- Token exchange now uses account.api.switchbot.net/merchant/v1/oauth/token (JSON body)
- Update OAUTH_SCOPE from 'openid' to 'api_login'
- Add SP_OAUTH_LOGIN_URL and ENDPOINTS.oauthToken constants
- Add User-Agent: switchbot-cli/<version> to all outgoing HTTP requests:
  - buildAuthHeaders() covers main API client + verifyCredentials
  - local-login-server.ts standalone axios calls (email/password + Wonder API)
- Remove directOAuth option from BrowserLoginOptions (single code path now)
- Update tests: remove Cognito URL assertions, fix drain mechanism

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ials

- Add TTY countdown timer to browser-login.ts showing remaining seconds
  while waiting for browser login to complete
- Rename OAUTH_CLIENT_ID → ACCOUNT_CLIENT_ID for email/password flow
  (emvg3hk2tqu3q37fcw6cwyl4bi is the consumer app client, not merchant OAuth)
- Add OAUTH_CLIENT_ID (wrZlijGQevZHVyGeINSQGUVEHw) and OAUTH_CLIENT_SECRET
  for the merchant OAuth2 flow via sp.oauth.switchbot.net
- Include clientSecret in token exchange request body (required by merchant API)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The merchant OAuth client (wrZlijGQevZHVyGeINSQGUVEHw) has
http://127.0.0.1:53245/callback registered as its redirect URI.
Using a random port caused "redirectUri not match" (statusCode 190).

- Add OAUTH_CALLBACK_PORT = 53245 constant
- bindCallbackServer defaults to port 53245 (listens via server.listen
  for proper EADDRINUSE error), reads actual port via server.address()
- Accept optional port param so tests pass 0 for OS-assigned random ports
- Update oauth-callback tests to pass port=0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…le/management/login

mobile/management/login is a device-trust endpoint and does not return
openToken/secretKey. The correct flow (same as email/password path) is:
  1. POST /merchant/v1/oauth/token (form-encoded) → access_token
  2. POST /account/api/v1/user/userinfo → botRegion
  3. POST wonderlabs.{region}.api.switchbot.net/wonder/openapi/openUser/token
     → AES-128-CBC encrypted token + secretKey → decrypt

Also removes MOBILE_API_BASE dependency from token-exchange.ts and
updates tests to cover the 3-step flow with userinfo fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Avoids the previous multi-line format looking like sequential commands.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ire (F-4)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mcpError() now formats content[0].text as:
  <kind> error (code <N>): <message>
  [hint line if present]
  --- structured ---
  { "error": { ... } }

MCP clients that only show the first line now display a readable
summary instead of raw JSON. structuredContent is unchanged.

Updated existing tests to use parseErrorText() helper to extract
the JSON block from after the --- structured --- marker.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… (A-1)

The --json fast path bypassed resolveFields(), so --fields was silently
ignored. Now field projection is applied to both filtered and unfiltered
device list output before calling printJson().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add regression tests confirming that empty deviceId, unsupported catalog
command, missing required parameter, and empty scene ID all exit with
code 2 rather than 0. All four validation paths were already correct
(UsageError/StructuredUsageError/exitWithError with code:2); tests make
this contract explicit and guard against regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(F-5)

Replace key=0 with midpoint of [fromMs, toMs] so the single bucket
timestamp falls within the queried range instead of epoch 1970-01-01.
Guards against infinite toMs (--since without --to) by clamping to now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add getCachedStatusEntry() to cache.ts that returns { body, fetchedAt }
with the stored timestamp. Update the single-device status path in
commands/devices.ts to use the stored fetchedAt when a cache entry is
present, so cached responses show the original fetch time rather than
the current render time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-2 follow-up)

Add parseErrorText() helper and update 4 error-response assertions to
extract JSON from after the '--- structured ---' delimiter introduced by
the F-2 mcpError prefix change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
chenliuyun and others added 28 commits May 21, 2026 23:20
…in makeDataItems; clear existsSpy in test beforeEach
…gin orchestration, getCachedStatusEntry, and stale cache fallback
…N field projection, and missing-creds hint

- reset: drop sibling `cache/` from dataItems when --config is active. The
  CLI never creates that path in --config mode (cache.ts:scopedCacheDir is
  only used when getConfigPath() is unset), so the previous unconditional
  rmSync could wipe an unrelated project's data alongside the override.

- devices list --json --fields: project canonical keys (deviceId/deviceName)
  on output instead of echoing the user-supplied alias (id/name). The JSON
  schema is now stable across --json and --format json regardless of
  which input form the caller used.

- config: credential-missing hint now round-trips --config <path> in both
  the `auth login` and `config set-token` recovery commands. Bare `auth
  login` writes to the default backend, which loadConfig ignores while
  --config is active, so the suggestion would not actually unblock the
  workflow.

Each fix has a paired RED-then-GREEN regression test.
…sistency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…table mocking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Non-interactive (no TTY): exit 1 with explanation
- User typed 'n' in TTY: exit 0 with simple 'Aborted.'
- Remove unreachable return after process.exit(1)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chenliuyun chenliuyun merged commit cf333da into main May 22, 2026
11 checks passed
@chenliuyun chenliuyun deleted the feat/browser-login branch May 22, 2026 06:04
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