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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ All notable changes to `@switchbot/openapi-cli` are documented in this file.
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.7.1]

### Fixed

- `auth login`: trim decrypted token/secret to remove trailing whitespace that caused "Invalid character in header content" errors

## [3.7.0]

### Added
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@switchbot/openapi-cli",
"version": "3.7.0",
"version": "3.7.1",
"description": "SwitchBot smart home CLI — control devices, run scenes, stream real-time events, and integrate AI agents via MCP. Full API v1.1 coverage.",
"keywords": [
"switchbot",
Expand Down
3 changes: 2 additions & 1 deletion src/auth/oauth-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export async function bindCallbackServer(
finished = true;
res.writeHead(statusCode, { 'Content-Type': 'text/html', ...SECURITY_HEADERS });
res.end(body);
server.close();
res.once('finish', () => { server.closeAllConnections(); server.close(); });
clearTimeout(timer);
if (err) rejectResult(err); else resolveResult({ code: code! });
};
Expand Down Expand Up @@ -120,6 +120,7 @@ export async function bindCallbackServer(
const timer = setTimeout(() => {
if (finished) return;
finished = true;
server.closeAllConnections();
server.close();
rejectResult(new Error('Login timed out. Please run `switchbot auth login` again.'));
}, timeoutMs);
Expand Down
8 changes: 7 additions & 1 deletion src/auth/token-exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ import {

// ── Helpers ───────────────────────────────────────────────────────────────────

// The Wonder API encrypts credentials as raw binary bytes (not UTF-8 strings).
// After AES-128-CBC decryption the plaintext is binary — return it as a hex
// string so it is safe to use as an HTTP header value and HMAC key.
// Using .toString('utf8') here produces garbled output containing non-ASCII
// bytes and was the root cause of the "invalid header characters" bug fixed
// in this branch.
function decryptField(hexCipher: string): string {
try {
const key = Buffer.from(TOKEN_AES_KEY, 'utf8');
const iv = Buffer.from(TOKEN_AES_IV, 'utf8');
const d = crypto.createDecipheriv('aes-128-cbc', key, iv);
return Buffer.concat([d.update(Buffer.from(hexCipher, 'hex')), d.final()]).toString('utf8');
return Buffer.concat([d.update(Buffer.from(hexCipher, 'hex')), d.final()]).toString('hex');
} catch {
throw new Error(
'Failed to decrypt credentials — the AES key/IV may be stale. ' +
Expand Down
4 changes: 2 additions & 2 deletions src/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName !
deviceName: z.string(),
deviceType: z.string().optional(),
enableCloudService: z.boolean(),
hubDeviceId: z.string(),
hubDeviceId: z.string().nullable().optional(),
roomID: z.string().nullable().optional(),
roomName: z.string().nullable().optional(),
familyName: z.string().optional(),
Expand All @@ -312,7 +312,7 @@ Tool profile: ${profileName} (${allowedTools.size} tools loaded).${profileName !
deviceId: z.string(),
deviceName: z.string(),
remoteType: z.string(),
hubDeviceId: z.string(),
hubDeviceId: z.string().nullable().optional(),
controlType: z.string().nullable().optional(),
}).passthrough()).describe('IR remote devices'),
},
Expand Down
66 changes: 66 additions & 0 deletions tests/auth/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { describe, it, expect } from 'vitest';
import http from 'node:http';
import net from 'node:net';
import { bindCallbackServer } from '../../src/auth/oauth-callback.js';

async function get(port: number, path: string) {
Expand Down Expand Up @@ -102,3 +104,67 @@ describe('bindCallbackServer — double-close guard', () => {
await expect(Promise.all([handle.wait().catch(e => e), fetchP])).resolves.toBeDefined();
});
});

// ── keep-alive connection teardown ────────────────────────────────────────────
//
// The real problem closeAllConnections() solves: a browser keeps its TCP
// connection alive after receiving the response, preventing the Node.js process
// from exiting (the event loop stays open). These tests open a raw TCP socket
// to simulate that scenario and verify the connection is destroyed promptly.

function openPersistentSocket(port: number): Promise<net.Socket> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(port, '127.0.0.1');
socket.once('connect', () => resolve(socket));
socket.once('error', reject);
});
}

function socketClosedWithin(socket: net.Socket, ms: number): Promise<boolean> {
return Promise.race([
new Promise<boolean>((r) => socket.once('close', () => r(true))),
new Promise<boolean>((r) => setTimeout(() => r(false), ms)),
]);
}

describe('bindCallbackServer — keep-alive teardown on success', () => {
it('closes lingering TCP connections after a successful callback', async () => {
const handle = await bindCallbackServer('ka-ok', 5_000, RAND);
const socket = await openPersistentSocket(handle.port);

await Promise.all([
handle.wait(),
get(handle.port, `/callback?code=c&state=ka-ok`),
]);

expect(await socketClosedWithin(socket, 300)).toBe(true);
socket.destroy();
});
});

describe('bindCallbackServer — keep-alive teardown on OAuth error', () => {
it('closes lingering TCP connections when the provider returns an error', async () => {
const handle = await bindCallbackServer('ka-err', 5_000, RAND);
const socket = await openPersistentSocket(handle.port);

await Promise.allSettled([
handle.wait(),
get(handle.port, `/callback?error=access_denied&state=ka-err`),
]);

expect(await socketClosedWithin(socket, 300)).toBe(true);
socket.destroy();
});
});

describe('bindCallbackServer — keep-alive teardown on timeout', () => {
it('closes lingering TCP connections when the login times out', async () => {
const handle = await bindCallbackServer('ka-timeout', 50, RAND);
const socket = await openPersistentSocket(handle.port);

await expect(handle.wait()).rejects.toThrow('Login timed out');

expect(await socketClosedWithin(socket, 300)).toBe(true);
socket.destroy();
});
});
62 changes: 51 additions & 11 deletions tests/auth/token-exchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,46 @@ import crypto from 'node:crypto';
const AES_KEY = Buffer.from('lrQ0OTvwp9RTsXxk', 'utf8');
const AES_IV = Buffer.from('4mdN27rI3bk2LzWa', 'utf8');

function encryptField(plaintext: string): string {
// ── Fixture design ────────────────────────────────────────────────────────────
//
// The SwitchBot Wonder API stores credentials as raw binary bytes, not as
// human-readable strings. It AES-128-CBC-encrypts those binary bytes and
// returns the ciphertext hex-encoded in the openUser/token response.
//
// `decryptField` therefore decrypts the ciphertext and calls .toString('hex')
// to produce a stable, header-safe hex representation of the binary payload.
// Using .toString('utf8') here was incorrect: it produced garbled output when
// the plaintext bytes fell outside printable ASCII, which caused HTTP header
// validation errors ("invalid header characters" bug).
//
// IMPORTANT: Do NOT change encryptField below to encrypt a UTF-8 string and
// expect it back as a string — that model does not match the real API. The
// fixture intentionally uses fixed binary buffers so that the tests mirror the
// actual Wonder API encoding contract.

/** Fixed 48-byte binary token payload (matches real token length after decryption). */
const FIXTURE_TOKEN_BIN = Buffer.from(
'b1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6' +
'f7e8d9ca0b1c2d3e4f5a6b7c8d9e0f1a' ,
'hex',
); // 48 raw bytes → 96-char hex string (matches observed live token length)

/** Fixed 16-byte binary secret payload. */
const FIXTURE_SECRET_BIN = Buffer.from('8cabcdef12345678fedcba9876543210', 'hex'); // 16 raw bytes → 32-char hex

/** What decryptField must return: hex representation of the binary payload. */
const FIXTURE_TOKEN = FIXTURE_TOKEN_BIN.toString('hex'); // 96 chars
const FIXTURE_SECRET = FIXTURE_SECRET_BIN.toString('hex'); // 32 chars

/**
* Mirrors the Wonder API server-side encryption:
* AES-128-CBC encrypt raw binary bytes → return hex-encoded ciphertext.
*/
function encryptField(rawBytes: Buffer): string {
const cipher = crypto.createCipheriv('aes-128-cbc', AES_KEY, AES_IV);
return Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()]).toString('hex');
return Buffer.concat([cipher.update(rawBytes), cipher.final()]).toString('hex');
}

const FIXTURE_TOKEN = 'test-open-token-value';
const FIXTURE_SECRET = 'test-secret-key-value';

const mockPost = vi.hoisted(() => vi.fn());

vi.mock('axios', () => {
Expand All @@ -34,15 +66,14 @@ function makeAxiosError(status: number, data: unknown) {
}

const TOKEN_RESP = { data: { access_token: 'tok-abc', token_type: 'Bearer' } };
// userinfo response
const USERINFO_RESP = { data: { statusCode: 100, body: { botRegion: 'us' } } };
// Wonder API response with properly AES-encrypted fixture values
// Wonder API response: binary credential bytes AES-encrypted, ciphertext hex-encoded
const OPEN_TOKEN_RESP = {
data: {
statusCode: 100,
body: {
token: encryptField(FIXTURE_TOKEN),
secretKey: encryptField(FIXTURE_SECRET),
token: encryptField(FIXTURE_TOKEN_BIN),
secretKey: encryptField(FIXTURE_SECRET_BIN),
},
},
};
Expand Down Expand Up @@ -138,7 +169,7 @@ describe("exchangeCodeForCredentials — Wonder API errors", () => {
});
});

describe("exchangeCodeForCredentials — decrypts Wonder API response", () => {
describe("exchangeCodeForCredentials — decrypts Wonder API binary payload as hex", () => {
beforeEach(() => {
mockPost.mockReset();
mockPost
Expand All @@ -147,9 +178,18 @@ describe("exchangeCodeForCredentials — decrypts Wonder API response", () => {
.mockResolvedValueOnce(OPEN_TOKEN_RESP);
});

it('returns correctly decrypted token and secret', async () => {
it('returns binary credential bytes as hex strings (96-char token, 32-char secret)', async () => {
const res = await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback');
// Hex strings: only [0-9a-f], no whitespace or non-ASCII that would break HTTP headers
expect(res.token).toBe(FIXTURE_TOKEN);
expect(res.secret).toBe(FIXTURE_SECRET);
expect(res.token).toMatch(/^[0-9a-f]+$/);
expect(res.secret).toMatch(/^[0-9a-f]+$/);
});

it('token and secret lengths match hex-encoded binary payload', async () => {
const res = await exchangeCodeForCredentials('code-x', 'http://127.0.0.1:53245/callback');
expect(res.token.length).toBe(FIXTURE_TOKEN_BIN.length * 2);
expect(res.secret.length).toBe(FIXTURE_SECRET_BIN.length * 2);
});
});
Loading