feat: browser-based OAuth login + smoke-test bug fixes + codebase cleanup#50
Merged
Conversation
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>
…Region, body cap, guards)
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>
…l delete failure as 'failed'
…tructure after await
…er-record Date.now() drift
…rdcoded ~/.switchbot
…in makeDataItems; clear existsSpy in test beforeEach
… when --config is set
…no-bucket stableKey within query window
… scoping and --to-only stableKey bound
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
sp.oauth.switchbot.netOAuth flow —switchbot auth loginopens 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 resetcommand: New command to wipe all local state (credentials, cache, config).switchbot auth loginsuggestion.--fieldsprojection, history aggregation timestamp, cachefetchedAtaccuracy, and--cacheflag documentation.outputSchemaboundary tests,mcpErrorformat-contract test, and sharedparseErrorTexthelper.familyandroomcolumns now shown by default (no--wideneeded).web/login.html, orphanedlocal-login-server.ts, completed plan/spec docs, stale design docs, andcontrib/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 (viasaveConfig()) instead of the OS keychain or~/.switchbot/config.json. Prevents silent credential mismatch in portable setups.reset --config[P1]:resetnow resolvesdevices.json,status.json,device-meta.json, and the cache dir relative to the--configdirectory instead of always using~/.switchbot.quota.json,device-history/, andaudit.logremain global (their writers hardcode~/.switchbot).reset --jsonexit code [P2]: JSON mode now exits 1 when any reset step fails (previously always exited 0).devices list --json --fields cloud[P2]:cloudfield now emitsboolean/nullinstead of'true'/'false'/'—'strings when using the--fieldsprojection path.--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.Date.now()is now called once before thefor awaitloop instead of per-record, preventing multiple spurious buckets when--tois omitted.Test plan
switchbot auth login— openssp.oauth.switchbot.netin browser, completes OAuth, saves credentialsswitchbot auth login --no-open— prints login URL with correctclient_id,redirect_uri=http://127.0.0.1:53245/callback,scope=api_loginswitchbot --config ./portable.json auth login --no-open— credentials saved toportable.json, not to keychainswitchbot devices list— showsfamilyandroomcolumns by defaultswitchbot devices list --json --fields cloud—cloudistrue/false(boolean), not"true"/"false"(string)switchbot reset— wipes local stateswitchbot --config ./portable.json reset --yes— wipes files adjacent toportable.jsonnpm test— all tests pass (2645/2650; 5 pre-existingdoctor.test.tsfailures unrelated to this branch)npm run build— clean build, no TypeScript errors