homebridge-ecobee-security 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - Sign in from the plugin's settings page in the Homebridge UI with your ecobee email, password
6
+ and MFA code. No terminal or browser developer tools needed; the website sign-in is still there
7
+ as a fallback.
8
+ - `bin/ecobee-login.js` signs in with email and password by default; `--browser` for the old way.
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - Control socket (`control.sock`) so other plugins can hold the system at Stay and release it to
package/README.md CHANGED
@@ -27,21 +27,25 @@ Running it as a child bridge is recommended. Requires Node 22 or 24 and Homebrid
27
27
 
28
28
  ## Sign-in
29
29
 
30
- ecobee's login page shows a CAPTCHA to scripts and the account may use MFA, so the plugin doesn't
31
- take your password. You sign in once in Chrome and give the plugin a refresh token:
30
+ Open the plugin's **Settings** in the Homebridge UI and sign in with your ecobee email and password,
31
+ then the verification code if your account uses MFA. The plugin saves a refresh token in its config;
32
+ your password is sent only to ecobee and isn't stored. Restart the bridge afterwards.
32
33
 
33
- ```
34
- node <plugin folder>/bin/ecobee-login.js
35
- ```
34
+ The plugin renews its access token every 2 hours and keeps the current refresh token in
35
+ `<homebridge storage>/ecobee-security/state.json` (mode 600). A newly saved token always replaces the
36
+ stored one. You only need to sign in again if ecobee revokes the token (for example after a password
37
+ change or "sign out of all devices"); the log then says so and the accessory shows a fault.
38
+
39
+ **If sign-in fails** (for example ecobee asks for a CAPTCHA), the settings page also offers sign-in
40
+ on ecobee's website: you sign in in a desktop browser and paste the final redirect URL from its
41
+ developer tools.
36
42
 
37
- (any machine with Node 22+; on the Homebridge Pi: `/opt/homebridge/bin/node /var/lib/homebridge/node_modules/homebridge-ecobee-security/bin/ecobee-login.js`).
38
- It prints a link and step-by-step instructions, then the refresh token. Paste the token into the
39
- plugin settings and restart the bridge. The plugin renews its access token every 2 hours and keeps
40
- the current refresh token in `<homebridge storage>/ecobee-security/state.json` (mode 600). A newly
41
- pasted token always replaces the stored one.
43
+ **Without the Homebridge UI**, sign in from a terminal and paste the printed token into the config:
42
44
 
43
- You only need to sign in again if ecobee revokes the token (for example after a password change or
44
- "sign out of all devices"); the log then says so and the accessory shows a fault.
45
+ ```
46
+ node <plugin folder>/bin/ecobee-login.js # email, password, MFA code
47
+ node <plugin folder>/bin/ecobee-login.js --browser # website sign-in
48
+ ```
45
49
 
46
50
  ## Config
47
51
 
@@ -1,56 +1,70 @@
1
1
  #!/usr/bin/env node
2
- // Browser sign-in for homebridge-ecobee-security. ecobee's login page shows a CAPTCHA to scripts,
3
- // so you sign in yourself in Chrome and paste the final redirect URL here. Prints a refresh token
4
- // to paste into the plugin settings (Homebridge UI > Plugins > ecobee Smart Security > Settings).
5
- import { createHash, randomBytes } from 'node:crypto';
2
+ // Sign-in for homebridge-ecobee-security from a terminal, for when the Homebridge UI isn't
3
+ // available (its plugin settings page has the same sign-in). Prints a refresh token to paste into
4
+ // the plugin config.
5
+ //
6
+ // ecobee-login.js email, password and MFA code (the password is never saved)
7
+ // ecobee-login.js --browser sign in on ecobee's web page instead and paste the callback URL
6
8
  import readline from 'node:readline';
7
9
 
8
- import { AUDIENCE, AUTH, CLIENT_ID, REDIRECT_URI, SCOPE, tokenRequest } from '../lib/ecobee.js';
10
+ import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn } from '../lib/auth.js';
9
11
 
10
- function ask(question) {
11
- return new Promise(resolve => {
12
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
13
- rl.question(question, answer => {
14
- rl.close();
15
- resolve(answer.trim());
16
- });
17
- });
12
+ // One reader for the whole run. Lines are queued, so piped input that arrives before the next
13
+ // question isn't lost.
14
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
15
+ let muted = false;
16
+ rl._writeToOutput = text => {
17
+ if (!muted) {
18
+ rl.output.write(text);
19
+ }
20
+ };
21
+ const lines = [];
22
+ const waiting = [];
23
+ rl.on('line', line => (waiting.length ? waiting.shift()(line) : lines.push(line)));
24
+ rl.on('close', () => waiting.splice(0).forEach(resolve => resolve('')));
25
+
26
+ async function ask(question, { hidden = false } = {}) {
27
+ rl.output.write(question);
28
+ // Hide what's typed after the prompt.
29
+ muted = hidden;
30
+ const answer = lines.length ? lines.shift() : await new Promise(resolve => waiting.push(resolve));
31
+ muted = false;
32
+ if (hidden) {
33
+ rl.output.write('\n');
34
+ }
35
+ return answer.trim();
18
36
  }
19
37
 
20
- async function main() {
21
- const verifier = randomBytes(32).toString('base64url');
22
- const state = randomBytes(24).toString('base64url');
23
- const url = new URL('/authorize', AUTH);
24
- Object.entries({
25
- audience: AUDIENCE, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPE,
26
- response_type: 'code', prompt: 'login', state, nonce: randomBytes(16).toString('hex'),
27
- code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256',
28
- }).forEach(([k, v]) => url.searchParams.set(k, v));
38
+ async function withPassword() {
39
+ console.log('\nSign in to ecobee. Your password goes only to auth.ecobee.com and isn\'t saved.\n');
40
+ const username = await ask('ecobee email: ');
41
+ const password = await ask('Password (hidden): ', { hidden: true });
42
+ const { tokens, mfa } = await passwordSignIn(username, password);
43
+ if (tokens) {
44
+ return tokens;
45
+ }
46
+ const code = await ask(mfa.method === 'oob' ? 'Code ecobee just sent you: ' : 'Code from your authenticator app: ');
47
+ return finishMfa(mfa, code);
48
+ }
29
49
 
50
+ async function withBrowser() {
51
+ const link = browserSignInLink();
30
52
  console.log(`
31
53
  1. In Chrome, open a new tab, press Option-Command-I, go to Network and tick "Preserve log".
32
54
  2. Open this link in that tab and sign in (email, password, CAPTCHA, MFA code):
33
55
 
34
- ${url.href}
56
+ ${link.url}
35
57
 
36
58
  3. When the page goes blank, type code= in the Network filter, right-click the entry and
37
59
  Copy > Copy URL (or: the last "resume" request > Headers > Response Headers > location).
38
60
  It starts with com.ecobee.athenamobile://
39
61
  4. Paste it below straight away; the code expires quickly.
40
62
  `);
63
+ return finishBrowserSignIn(link, await ask('Callback URL: '));
64
+ }
41
65
 
42
- const callback = new URL(await ask('Callback URL: '));
43
- if (callback.searchParams.get('state') !== state) {
44
- throw new Error('that URL is from a different sign-in attempt; run this again');
45
- }
46
- const code = callback.searchParams.get('code');
47
- if (!code) {
48
- throw new Error('no code= in that URL');
49
- }
50
- const tokens = await tokenRequest({ grant_type: 'authorization_code', code, code_verifier: verifier, redirect_uri: REDIRECT_URI });
51
- if (!tokens.refresh_token) {
52
- throw new Error('signed in, but ecobee returned no refresh token');
53
- }
66
+ async function main() {
67
+ const tokens = process.argv.includes('--browser') ? await withBrowser() : await withPassword();
54
68
  console.log(`
55
69
  Signed in. Paste this refresh token into the plugin settings ("Refresh token"), save, and restart
56
70
  the bridge. Treat it like a password: it gives full access to your ecobee account.
@@ -59,7 +73,7 @@ ${tokens.refresh_token}
59
73
  `);
60
74
  }
61
75
 
62
- main().catch(error => {
76
+ main().then(() => rl.close(), error => {
63
77
  console.error(`Sign-in failed: ${error.message}`);
64
78
  process.exit(1);
65
79
  });
@@ -2,10 +2,10 @@
2
2
  "pluginAlias": "EcobeeSecurityPlatform",
3
3
  "pluginType": "platform",
4
4
  "singular": true,
5
- "headerDisplay": "Shows ecobee Smart Security as a security system in Apple Home (arm Stay / Away, disarm, alarm state). Unofficial: uses the same ecobee cloud as the app. To get a refresh token, run bin/ecobee-login.js from this plugin's folder (see README).",
5
+ "customUi": true,
6
+ "headerDisplay": "Shows ecobee Smart Security as a security system in Apple Home (arm Stay / Away, disarm, alarm state). Unofficial: uses the same ecobee cloud as the app.",
6
7
  "schema": {
7
8
  "type": "object",
8
- "required": ["refreshToken"],
9
9
  "properties": {
10
10
  "name": {
11
11
  "title": "Name",
@@ -16,8 +16,10 @@
16
16
  "refreshToken": {
17
17
  "title": "Refresh token",
18
18
  "type": "string",
19
- "x-schema-form": { "type": "password" },
20
- "description": "From bin/ecobee-login.js. Only needed again if ecobee signs the plugin out (password change, 'sign out everywhere')."
19
+ "x-schema-form": {
20
+ "type": "password"
21
+ },
22
+ "description": "Filled in when you sign in above. Only needed again if ecobee signs the plugin out (password change, 'sign out everywhere')."
21
23
  },
22
24
  "homeId": {
23
25
  "title": "Home ID",
@@ -0,0 +1,182 @@
1
+ <style>
2
+ /* Inherit the theme's text colour; Bootstrap's muted grey is unreadable on Homebridge's dark themes. */
3
+ .hint { opacity: 0.75; font-size: 0.875em; }
4
+ </style>
5
+
6
+ <div class="card card-body mb-3">
7
+ <h5 class="mb-3">ecobee account</h5>
8
+
9
+ <div id="signed-in" hidden>
10
+ <div class="alert alert-success mb-2" role="status" id="signed-in-text">Signed in to ecobee.</div>
11
+ <p class="hint mb-2" id="signed-in-hint">You only need to sign in again if the log says ecobee signed the plugin out (for example after a password change).</p>
12
+ <button type="button" class="btn btn-secondary" id="sign-in-again">Sign in again</button>
13
+ </div>
14
+
15
+ <div id="signin" hidden>
16
+ <p class="hint mb-3" id="signin-hint">Sign in with your ecobee account to connect the plugin.</p>
17
+ <form id="password-form" autocomplete="on">
18
+ <div class="mb-2">
19
+ <label for="email" class="form-label">ecobee email</label>
20
+ <input type="email" class="form-control" id="email" autocomplete="username" required>
21
+ </div>
22
+ <div class="mb-2">
23
+ <label for="password" class="form-label">Password</label>
24
+ <input type="password" class="form-control" id="password" autocomplete="current-password" required>
25
+ <div class="hint mt-1">Sent only to ecobee to sign in. The plugin keeps a sign-in token, never your password.</div>
26
+ </div>
27
+ <button type="submit" class="btn btn-primary">Sign in</button>
28
+ <button type="button" class="btn btn-link" id="sign-in-cancel" hidden>Cancel</button>
29
+ </form>
30
+
31
+ <form id="mfa-form" hidden>
32
+ <div class="mb-2">
33
+ <label for="code" class="form-label" id="code-label">Verification code</label>
34
+ <input type="text" class="form-control" id="code" inputmode="numeric" autocomplete="one-time-code" maxlength="10" required>
35
+ </div>
36
+ <button type="submit" class="btn btn-primary">Verify</button>
37
+ <button type="button" class="btn btn-link" id="mfa-cancel">Start over</button>
38
+ </form>
39
+
40
+ <div id="error" class="alert alert-danger mt-3 mb-0" role="alert" hidden></div>
41
+
42
+ <details class="mt-3" id="browser">
43
+ <summary>Sign in on ecobee's website instead</summary>
44
+ <p class="hint mt-2 mb-2">Use this if signing in above keeps failing (for example if ecobee asks for a CAPTCHA). You'll need a desktop browser with developer tools.</p>
45
+ <ol class="small ps-3">
46
+ <li>In Chrome, open a new tab, open DevTools (Option-Command-I or F12), go to <b>Network</b> and tick <b>Preserve log</b>.</li>
47
+ <li>In that tab, open <a id="browser-link" href="#" target="_blank" rel="noopener">this sign-in link</a> and sign in.</li>
48
+ <li>When the page goes blank, type <code>code=</code> in the Network filter, right-click the entry and choose <b>Copy &gt; Copy URL</b>. It starts with <code>com.ecobee.athenamobile://</code></li>
49
+ <li>Paste it here straight away (the code expires within a minute):</li>
50
+ </ol>
51
+ <form id="browser-form" class="d-flex gap-2">
52
+ <input type="text" class="form-control" id="callback" placeholder="com.ecobee.athenamobile://…" required>
53
+ <button type="submit" class="btn btn-primary">Finish</button>
54
+ </form>
55
+ </details>
56
+ </div>
57
+ </div>
58
+
59
+ <script>
60
+ (async () => {
61
+ const PLATFORM = 'EcobeeSecurityPlatform';
62
+ const $ = id => document.getElementById(id);
63
+ const passwordForm = $('password-form');
64
+ const mfaForm = $('mfa-form');
65
+
66
+ function showError(message) {
67
+ $('error').textContent = message;
68
+ $('error').hidden = !message;
69
+ }
70
+
71
+ /** Show the signed-in summary, or the sign-in forms if there's no token (or the user asked). */
72
+ async function showStatus({ justSignedIn = false, signIn = false } = {}) {
73
+ const [block] = await homebridge.getPluginConfig();
74
+ const signedIn = Boolean(block?.refreshToken) && !signIn;
75
+ $('signed-in').hidden = !signedIn;
76
+ $('signin').hidden = signedIn;
77
+ $('signed-in-text').textContent = justSignedIn
78
+ ? 'Signed in and saved. Restart the bridge this plugin runs on (or Homebridge) to use the new sign-in.'
79
+ : 'Signed in to ecobee.';
80
+ $('sign-in-cancel').hidden = !block?.refreshToken;
81
+ $('signin-hint').textContent = block?.refreshToken
82
+ ? 'Signing in again replaces the saved sign-in.'
83
+ : 'Sign in with your ecobee account to connect the plugin.';
84
+ }
85
+
86
+ async function call(path, body) {
87
+ showError('');
88
+ homebridge.showSpinner();
89
+ try {
90
+ return await homebridge.request(path, body);
91
+ } catch (error) {
92
+ showError(error.message || 'Sign-in failed.');
93
+ return undefined;
94
+ } finally {
95
+ homebridge.hideSpinner();
96
+ }
97
+ }
98
+
99
+ async function saveToken(refreshToken) {
100
+ const blocks = await homebridge.getPluginConfig();
101
+ const block = blocks[0] ?? { platform: PLATFORM, name: 'Security System' };
102
+ block.refreshToken = refreshToken;
103
+ // Re-show the settings form afterwards so it picks up the new token.
104
+ homebridge.hideSchemaForm();
105
+ await homebridge.updatePluginConfig([block, ...blocks.slice(1)]);
106
+ await homebridge.savePluginConfig();
107
+ homebridge.showSchemaForm();
108
+ passwordForm.reset();
109
+ mfaForm.reset();
110
+ passwordForm.hidden = false;
111
+ mfaForm.hidden = true;
112
+ $('callback').value = '';
113
+ $('browser').open = false;
114
+ homebridge.toast.success('Signed in to ecobee.');
115
+ await showStatus({ justSignedIn: true });
116
+ }
117
+
118
+ passwordForm.addEventListener('submit', async event => {
119
+ event.preventDefault();
120
+ const result = await call('/password', { email: $('email').value, password: $('password').value });
121
+ $('password').value = '';
122
+ if (result?.refreshToken) {
123
+ await saveToken(result.refreshToken);
124
+ } else if (result?.mfa) {
125
+ $('code-label').textContent = result.mfa === 'oob'
126
+ ? 'Enter the code ecobee just sent you'
127
+ : 'Enter the code from your authenticator app';
128
+ passwordForm.hidden = true;
129
+ mfaForm.hidden = false;
130
+ $('code').focus();
131
+ }
132
+ });
133
+
134
+ mfaForm.addEventListener('submit', async event => {
135
+ event.preventDefault();
136
+ const result = await call('/mfa', { code: $('code').value });
137
+ $('code').value = '';
138
+ if (result?.refreshToken) {
139
+ await saveToken(result.refreshToken);
140
+ }
141
+ });
142
+
143
+ $('mfa-cancel').addEventListener('click', () => {
144
+ showError('');
145
+ mfaForm.reset();
146
+ mfaForm.hidden = true;
147
+ passwordForm.hidden = false;
148
+ });
149
+
150
+ $('sign-in-again').addEventListener('click', async () => {
151
+ showError('');
152
+ await showStatus({ signIn: true });
153
+ $('email').focus();
154
+ });
155
+
156
+ $('sign-in-cancel').addEventListener('click', async () => {
157
+ showError('');
158
+ passwordForm.reset();
159
+ await showStatus();
160
+ });
161
+
162
+ $('browser').addEventListener('toggle', async () => {
163
+ if ($('browser').open) {
164
+ const result = await call('/browser-link');
165
+ if (result?.url) {
166
+ $('browser-link').href = result.url;
167
+ }
168
+ }
169
+ });
170
+
171
+ $('browser-form').addEventListener('submit', async event => {
172
+ event.preventDefault();
173
+ const result = await call('/browser-finish', { callbackUrl: $('callback').value });
174
+ if (result?.refreshToken) {
175
+ await saveToken(result.refreshToken);
176
+ }
177
+ });
178
+
179
+ await showStatus();
180
+ homebridge.showSchemaForm();
181
+ })();
182
+ </script>
@@ -0,0 +1,66 @@
1
+ // Server side of the plugin's settings page (spawned by the Homebridge UI while the settings are
2
+ // open). Does the ecobee sign-in and hands only the refresh token back to the page, which saves it
3
+ // in the plugin config. The MFA token and PKCE verifier stay here, in memory.
4
+ import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils';
5
+
6
+ import { browserSignInLink, finishBrowserSignIn, finishMfa, passwordSignIn, SignInError } from '../lib/auth.js';
7
+
8
+ class UiServer extends HomebridgePluginUiServer {
9
+ constructor() {
10
+ super();
11
+ this.mfa = undefined;
12
+ this.link = undefined;
13
+
14
+ this.onRequest('/password', ({ email, password } = {}) => this.guard(async () => {
15
+ if (!email || !password) {
16
+ throw new SignInError('Enter your ecobee email and password.', 'missing');
17
+ }
18
+ this.mfa = undefined;
19
+ const { tokens, mfa } = await passwordSignIn(String(email).trim(), String(password));
20
+ if (tokens) {
21
+ return { refreshToken: tokens.refresh_token };
22
+ }
23
+ this.mfa = mfa;
24
+ return { mfa: mfa.method };
25
+ }));
26
+
27
+ this.onRequest('/mfa', ({ code } = {}) => this.guard(async () => {
28
+ if (!this.mfa) {
29
+ throw new SignInError('The sign-in timed out. Enter your email and password again.', 'no_mfa');
30
+ }
31
+ const tokens = await finishMfa(this.mfa, code ?? '');
32
+ this.mfa = undefined;
33
+ return { refreshToken: tokens.refresh_token };
34
+ }));
35
+
36
+ this.onRequest('/browser-link', () => {
37
+ this.link = browserSignInLink();
38
+ return { url: this.link.url };
39
+ });
40
+
41
+ this.onRequest('/browser-finish', ({ callbackUrl } = {}) => this.guard(async () => {
42
+ if (!this.link) {
43
+ throw new SignInError('Get a sign-in link first.', 'no_link');
44
+ }
45
+ const tokens = await finishBrowserSignIn(this.link, callbackUrl ?? '');
46
+ this.link = undefined;
47
+ return { refreshToken: tokens.refresh_token };
48
+ }));
49
+
50
+ this.ready();
51
+ }
52
+
53
+ /** Turn sign-in failures into RequestErrors so the page can show ecobee's reason. */
54
+ async guard(fn) {
55
+ try {
56
+ return await fn();
57
+ } catch (error) {
58
+ if (error instanceof SignInError) {
59
+ throw new RequestError(error.message, { code: error.code });
60
+ }
61
+ throw new RequestError(`Sign-in failed: ${error.message}`, { code: 'unexpected' });
62
+ }
63
+ }
64
+ }
65
+
66
+ (() => new UiServer())();
package/lib/auth.js ADDED
@@ -0,0 +1,121 @@
1
+ // ecobee sign-in, shared by bin/ecobee-login.js and the Homebridge UI settings page.
2
+ //
3
+ // Password sign-in goes straight to Auth0's token endpoint as the ecobee app's client (no login
4
+ // page, so no CAPTCHA), followed by the account's MFA code if it has MFA. The browser sign-in is
5
+ // the fallback: the user signs in on ecobee's page and pastes the app callback URL.
6
+ // Only the refresh token is kept; passwords and codes are passed through and never stored.
7
+ import { createHash, randomBytes } from 'node:crypto';
8
+
9
+ import { AUDIENCE, AUTH, CLIENT_ID, REDIRECT_URI, SCOPE } from './ecobee.js';
10
+
11
+ const TIMEOUT = 20 * 1000;
12
+
13
+ /** A sign-in step failed; `message` is ecobee's reason, fit to show the user. */
14
+ export class SignInError extends Error {
15
+ constructor(message, code) {
16
+ super(message);
17
+ this.code = code;
18
+ }
19
+ }
20
+
21
+ async function post(path, body) {
22
+ let res;
23
+ try {
24
+ res = await fetch(`${AUTH}${path}`, {
25
+ method: 'POST',
26
+ headers: { 'content-type': 'application/json' },
27
+ body: JSON.stringify({ client_id: CLIENT_ID, ...body }),
28
+ signal: AbortSignal.timeout(TIMEOUT),
29
+ });
30
+ } catch (error) {
31
+ throw new SignInError(`Could not reach ecobee (${error.message})`, 'network');
32
+ }
33
+ const json = await res.json().catch(() => ({}));
34
+ return { status: res.status, json };
35
+ }
36
+
37
+ function fail({ status, json }) {
38
+ const reason = json.error_description || json.error || `HTTP ${status}`;
39
+ return new SignInError(reason.endsWith('.') ? reason : `${reason}.`, json.error ?? `http_${status}`);
40
+ }
41
+
42
+ function tokens(json) {
43
+ if (!json.refresh_token) {
44
+ throw new SignInError('Signed in, but ecobee returned no refresh token.', 'no_refresh_token');
45
+ }
46
+ return json;
47
+ }
48
+
49
+ /**
50
+ * Step 1 of password sign-in. Resolves `{ tokens }` when no MFA is needed, otherwise
51
+ * `{ mfa }` to pass to finishMfa() together with the code ecobee asks for. `mfa.method` is
52
+ * 'otp' (authenticator app) or 'oob' (a code ecobee sends by SMS or email).
53
+ */
54
+ export async function passwordSignIn(username, password) {
55
+ const first = await post('/oauth/token', { grant_type: 'password', username, password, audience: AUDIENCE, scope: SCOPE });
56
+ if (first.status === 200) {
57
+ return { tokens: tokens(first.json) };
58
+ }
59
+ if (first.json.error !== 'mfa_required') {
60
+ throw fail(first);
61
+ }
62
+ const mfaToken = first.json.mfa_token;
63
+ const challenge = await post('/mfa/challenge', { mfa_token: mfaToken, challenge_type: 'otp oob' });
64
+ if (challenge.status !== 200) {
65
+ throw fail(challenge);
66
+ }
67
+ return {
68
+ mfa: {
69
+ token: mfaToken,
70
+ method: challenge.json.challenge_type === 'oob' ? 'oob' : 'otp',
71
+ oobCode: challenge.json.oob_code,
72
+ },
73
+ };
74
+ }
75
+
76
+ /** Step 2 of password sign-in: the MFA code. */
77
+ export async function finishMfa(mfa, code) {
78
+ const otp = String(code).replace(/\s+/g, '');
79
+ const result = mfa.method === 'oob'
80
+ ? await post('/oauth/token', { grant_type: 'http://auth0.com/oauth/grant-type/mfa-oob', mfa_token: mfa.token, oob_code: mfa.oobCode, binding_code: otp })
81
+ : await post('/oauth/token', { grant_type: 'http://auth0.com/oauth/grant-type/mfa-otp', mfa_token: mfa.token, otp });
82
+ if (result.status !== 200) {
83
+ throw fail(result);
84
+ }
85
+ return tokens(result.json);
86
+ }
87
+
88
+ /** Browser sign-in, step 1: the link to open, plus what finishBrowserSignIn() needs later. */
89
+ export function browserSignInLink() {
90
+ const verifier = randomBytes(32).toString('base64url');
91
+ const state = randomBytes(24).toString('base64url');
92
+ const url = new URL('/authorize', AUTH);
93
+ Object.entries({
94
+ audience: AUDIENCE, client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: SCOPE,
95
+ response_type: 'code', prompt: 'login', state, nonce: randomBytes(16).toString('hex'),
96
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256',
97
+ }).forEach(([k, v]) => url.searchParams.set(k, v));
98
+ return { url: url.href, verifier, state };
99
+ }
100
+
101
+ /** Browser sign-in, step 2: the com.ecobee.athenamobile://…?code=… URL the user copied. */
102
+ export async function finishBrowserSignIn(link, callbackUrl) {
103
+ let callback;
104
+ try {
105
+ callback = new URL(String(callbackUrl).trim());
106
+ } catch {
107
+ throw new SignInError('That isn\'t a URL. It should start with com.ecobee.athenamobile://', 'bad_url');
108
+ }
109
+ if (callback.searchParams.get('state') !== link.state) {
110
+ throw new SignInError('That URL is from a different sign-in attempt. Get a new link and try again.', 'bad_state');
111
+ }
112
+ const code = callback.searchParams.get('code');
113
+ if (!code) {
114
+ throw new SignInError('There\'s no code= in that URL.', 'bad_url');
115
+ }
116
+ const result = await post('/oauth/token', { grant_type: 'authorization_code', code, code_verifier: link.verifier, redirect_uri: REDIRECT_URI });
117
+ if (result.status !== 200) {
118
+ throw fail(result);
119
+ }
120
+ return tokens(result.json);
121
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-ecobee-security",
3
3
  "displayName": "ecobee Smart Security",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "description": "ecobee Smart Security as a HomeKit security system: arm Stay / Away, disarm, alarm state (unofficial, ecobee cloud)",
6
6
  "type": "module",
7
7
  "main": "index.js",
@@ -32,9 +32,13 @@
32
32
  "index.js",
33
33
  "lib",
34
34
  "bin",
35
+ "homebridge-ui",
35
36
  "config.schema.json",
36
37
  "README.md",
37
38
  "CHANGELOG.md",
38
39
  "LICENSE"
39
- ]
40
+ ],
41
+ "dependencies": {
42
+ "@homebridge/plugin-ui-utils": "^2.2.6"
43
+ }
40
44
  }