UNPKG

@homebridge-plugins/homebridge-smarthq

Version:

The SmartHQ plugin allows you to interact with SmartHQ Devices in HomeKit and with Siri.

229 lines 11.8 kB
import axios from 'axios'; import { wrapper } from 'axios-cookiejar-support'; import * as cheerio from 'cheerio'; import pkg from 'lodash'; import { Issuer } from 'openid-client'; import { CookieJar } from 'tough-cookie'; import { OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, OAUTH2_REDIRECT_URI, LOGIN_URL } from './settings.js'; const { keyBy, mapValues } = pkg; const oidcClient = Issuer.discover('https://accounts.brillion.geappliances.com/').then(geData => new geData.Client({ client_id: OAUTH2_CLIENT_ID, client_secret: OAUTH2_CLIENT_SECRET, response_types: ['code'], })); export async function refreshAccessToken(refresh_token) { const client = await oidcClient; return client.grant({ refresh_token, grant_type: 'refresh_token' }); } export default async function getAccessToken(username, password) { const client = await oidcClient; const oauthUrl = client.authorizationUrl(); const jar = new CookieJar(); const aclient = wrapper(axios.create({ jar })); const htmlPageResponse = await aclient.get(oauthUrl); const page = cheerio.load(htmlPageResponse.data); const carryInputs = mapValues(keyBy(page('#frmsignin').serializeArray(), o => o.name), t => t.value); const body = new URLSearchParams({ ...carryInputs, username, password }); const res = await aclient({ method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', 'origin': 'https://accounts.brillion.geappliances.com', }, url: 'https://accounts.brillion.geappliances.com/oauth2/g_authenticate', data: body, maxRedirects: 0, validateStatus: () => true, }); // If the initial POST did not return a redirect with the code, the login // flow may have hit an intermediate page (MFA enrollment or Terms acceptance). // Attempt to handle those pages automatically; fall back to an error with // guidance if not possible. const tryExtractCodeFromLocation = (location) => { if (!location) return null; try { return new URL(location).searchParams.get('code'); } catch (e) { // Handle relative URLs try { const full = location.startsWith('/') ? `${LOGIN_URL}${location}` : location; return new URL(full).searchParams.get('code'); } catch (e) { return null; } } }; let code = tryExtractCodeFromLocation(res.headers.location); if (!code) { // Try to handle HTML response pages (MFA or Terms) const asyncHandleOkResponse = async (respText) => { // MFA enrollment page if (respText.includes('Add Multi-Factor Authentication') || respText.includes('addMfaForm')) { try { const $ = cheerio.load(respText); const mfaForm = $('#addMfaForm'); if (!mfaForm || mfaForm.length === 0) { throw new Error('MFA form not found'); } const formData = {}; mfaForm.find('input').each((i, el) => { const name = $(el).attr('name'); if (!name) return; formData[name] = $(el).val() || ''; }); // Try meta tag for CSRF if not present if (!formData['_csrf']) { const csrfMeta = $('meta[name="_csrf"]').attr('content'); if (csrfMeta) formData['_csrf'] = csrfMeta; } const postData = new URLSearchParams(formData); const skipResp = await aclient({ method: 'POST', url: `${LOGIN_URL}/account/active/redirect`, data: postData, headers: { 'content-type': 'application/x-www-form-urlencoded' }, maxRedirects: 0, validateStatus: () => true, }); const loc = skipResp.headers.location; const gotCode = tryExtractCodeFromLocation(loc); if (gotCode) return gotCode; // Follow redirect manually if provided if (loc) { const resolved = loc.startsWith('/') ? `${LOGIN_URL}${loc}` : loc; const redirectResp = await aclient.get(resolved, { maxRedirects: 0, validateStatus: () => true }); const finalLoc = redirectResp.headers.location; const finalCode = tryExtractCodeFromLocation(finalLoc); if (finalCode) return finalCode; if (redirectResp.status === 200) return await asyncHandleOkResponse(await redirectResp.data); } if (skipResp.status === 200) return await asyncHandleOkResponse(await skipResp.data); throw new Error('MFA skip failed'); } catch (e) { throw new Error(`MFA handling failed: ${e.message}`); } } // Terms acceptance page if (respText.includes('Almost Finished') && respText.includes('/oauth2/terms/accept')) { try { const $ = cheerio.load(respText); let termsForm = $('#termsform'); if (!termsForm || termsForm.length === 0) { termsForm = $("form[name='termsform']"); } if (!termsForm || termsForm.length === 0) { // Fallback: try to extract required fields with regex const formData = {}; const sigMatch = respText.match(/name="signature"\\s+value="([^\"]+)"/); if (sigMatch) formData['signature'] = sigMatch[1]; const lasMatch = respText.match(/name="login_actions_signature"[^>]*value=([^>\\s>]+)/); if (lasMatch) formData['login_actions_signature'] = lasMatch[1].replace(/>$/, ''); const devMatch = respText.match(/name="isDeveloper"\\s+value="([^\"]+)"/); if (devMatch) formData['isDeveloper'] = devMatch[1]; const csrfMatch = respText.match(/name="_csrf"\\s+value="([^\"]+)"/); if (csrfMatch) formData['_csrf'] = csrfMatch[1]; formData['developerTerms'] = 'on'; formData['connected_terms'] = 'on'; const headers = {}; if (formData['_csrf']) headers['X-CSRF-TOKEN'] = formData['_csrf']; const termsResp = await aclient({ method: 'POST', url: `${LOGIN_URL}/oauth2/terms/accept`, data: new URLSearchParams(formData), headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers }, maxRedirects: 0, validateStatus: () => true, }); const loc = termsResp.headers.location; const gotCode = tryExtractCodeFromLocation(loc); if (gotCode) return gotCode; if (loc) { const resolved = loc.startsWith('/') ? `${LOGIN_URL}${loc}` : loc; const redirectResp = await aclient.get(resolved, { maxRedirects: 0, validateStatus: () => true }); const finalCode = tryExtractCodeFromLocation(redirectResp.headers.location); if (finalCode) return finalCode; if (redirectResp.status === 200) return await asyncHandleOkResponse(await redirectResp.data); } if (termsResp.status === 200) return await asyncHandleOkResponse(await termsResp.data); throw new Error('Terms acceptance failed'); } // Collect inputs from the form const formData = {}; termsForm.find('input').each((i, el) => { const name = $(el).attr('name'); if (!name) return; formData[name] = $(el).val() || ''; }); // Set checkboxes to accepted formData['developerTerms'] = 'on'; formData['connected_terms'] = 'on'; if (!formData['_csrf']) { const csrfMeta = $('meta[name="_csrf"]').attr('content'); if (csrfMeta) formData['_csrf'] = csrfMeta; } const headers = {}; if (formData['_csrf']) headers['X-CSRF-TOKEN'] = formData['_csrf']; const termsResp = await aclient({ method: 'POST', url: `${LOGIN_URL}/oauth2/terms/accept`, data: new URLSearchParams(formData), headers: { 'content-type': 'application/x-www-form-urlencoded', ...headers }, maxRedirects: 0, validateStatus: () => true, }); const loc = termsResp.headers.location; const gotCode = tryExtractCodeFromLocation(loc); if (gotCode) return gotCode; if (loc) { const resolved = loc.startsWith('/') ? `${LOGIN_URL}${loc}` : loc; const redirectResp = await aclient.get(resolved, { maxRedirects: 0, validateStatus: () => true }); const finalCode = tryExtractCodeFromLocation(redirectResp.headers.location); if (finalCode) return finalCode; if (redirectResp.status === 200) return await asyncHandleOkResponse(await redirectResp.data); } if (termsResp.status === 200) return await asyncHandleOkResponse(await termsResp.data); throw new Error('Terms acceptance failed'); } catch (e) { throw new Error(`Terms handling failed: ${e.message}`); } } throw new Error('Authentication failed: No authorization code received and no known intermediate page detected'); }; // If we have HTML in the response, try to handle it if (res.data && typeof res.data === 'string') { code = await asyncHandleOkResponse(res.data); } } if (!code) { throw new Error('Authentication failed: No authorization code received'); } return client.grant({ grant_type: 'authorization_code', code, redirect_uri: OAUTH2_REDIRECT_URI }); } //# sourceMappingURL=getAccessToken.js.map