@shadman-a/homebridge-my-ac
Version:
A Homebridge plugin for controlling/monitoring LG ThinQ devices via LG ThinQ platform.
341 lines • 15.4 kB
JavaScript
import crypto from 'crypto';
import { DateTime } from 'luxon';
import qs from 'qs';
import { URL } from 'url';
import { AuthenticationError, ManualProcessNeededErrorCode, TokenError } from '../errors/index.js';
import * as constants from './constants.js';
import { requestClient } from './request.js';
import { Session } from './Session.js';
/**
* Handles authentication with the LG ThinQ API.
* This class manages login, token refresh, and user session handling.
*/
export class Auth {
gateway;
logger;
/**
* The base URL for the LG API, determined by the user's country code.
*/
lgeapi_url;
/**
* Creates a new `Auth` instance.
*
* @param gateway - The `Gateway` instance containing API endpoint information.
* @param logger - The logger instance for logging debug and error messages.
*/
constructor(gateway, logger) {
this.gateway = gateway;
this.logger = logger;
this.lgeapi_url = `https://${this.gateway.country_code.toLowerCase()}.lgeapi.com/`;
}
/**
* Logs in to the LG ThinQ API using the provided username and password.
*
* @param username - The user's username.
* @param password - The user's password.
* @returns A promise that resolves with a `Session` instance.
*/
async login(username, password) {
// get signature and timestamp in login form
const hash = crypto.createHash('sha512');
return this.loginStep2(username, hash.update(password).digest('hex'));
}
/**
* Performs the second step of the login process using an encrypted password.
*
* @param username - The user's username.
* @param encrypted_password - The encrypted password.
* @param extra_headers - Optional additional headers for the request.
* @returns A promise that resolves with a `Session` instance.
*/
async loginStep2(username, encrypted_password, extra_headers) {
const headers = this.defaultEmpHeaders;
const preLoginData = {
'user_auth2': encrypted_password,
'log_param': 'login request / user_id : ' + username + ' / third_party : null / svc_list : SVC202,SVC710 / 3rd_service : ',
};
const preLogin = await requestClient.post(this.gateway.login_base_url + 'preLogin', qs.stringify(preLoginData), { headers })
.then(res => res.data);
headers['X-Signature'] = preLogin.signature;
headers['X-Timestamp'] = preLogin.tStamp;
const data = {
'user_auth2': preLogin.encrypted_pw,
'password_hash_prameter_flag': 'Y',
'svc_list': 'SVC202,SVC710', // SVC202=LG SmartHome, SVC710=EMP OAuth
...extra_headers,
};
// try login with username and hashed password
const loginUrl = this.gateway.emp_base_url + 'emp/v2.0/account/session/' + encodeURIComponent(username);
const account = await requestClient.post(loginUrl, qs.stringify(data), { headers }).then(res => res.data.account).catch(err => {
if (!err.response) {
throw err;
}
const { code, message } = err.response.data.error;
if (code === 'MS.001.03') {
throw new AuthenticationError('Your account was already used to registered in ' + message + '.');
}
throw new AuthenticationError(message);
});
// dynamic get secret key for emp signature
const empSearchKeyUrl = this.gateway.login_base_url + 'searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP';
const secretKey = await requestClient.get(empSearchKeyUrl).then(res => res.data).then(data => data.returnData);
const timestamp = DateTime.utc().toRFC2822();
const empData = {
account_type: account.userIDType,
client_id: constants.CLIENT_ID,
country_code: account.country,
redirect_uri: 'lgaccount.lgsmartthinq:/',
response_type: 'code',
state: '12345',
username: account.userID,
};
const empUrl = new URL('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession?' + qs.stringify(empData));
const signature = this.signature(`${empUrl.pathname}${empUrl.search}\n${timestamp}`, secretKey);
const empHeaders = {
'lgemp-x-app-key': constants.OAUTH_CLIENT_KEY,
'lgemp-x-date': timestamp,
'lgemp-x-session-key': account.loginSessionID,
'lgemp-x-signature': signature,
'Accept': 'application/json',
'X-Device-Type': 'M01',
'X-Device-Platform': 'ADR',
'Content-Type': 'application/x-www-form-urlencoded',
'Access-Control-Allow-Origin': '*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en-US,en;q=0.9',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36 Edg/93.0.961.44',
};
// create emp session and get access token
const authorize = await requestClient.get(empUrl.href, {
headers: empHeaders,
}).then(res => res.data).catch(err => {
throw new AuthenticationError(err.response.data.error.message);
});
if (authorize.status !== 1) {
throw new TokenError(authorize.message || authorize);
}
const redirect_uri = new URL(authorize.redirect_uri);
const tokenData = {
code: redirect_uri.searchParams.get('code'),
grant_type: 'authorization_code',
redirect_uri: empData.redirect_uri,
};
const requestUrl = '/oauth/1.0/oauth2/token?' + qs.stringify(tokenData);
const res = await requestClient.post(redirect_uri.searchParams.get('oauth2_backend_url') + 'oauth/1.0/oauth2/token', qs.stringify(tokenData), {
headers: {
'x-lge-app-os': 'ADR',
'x-lge-appkey': constants.CLIENT_ID,
'x-lge-oauth-signature': this.signature(`${requestUrl}\n${timestamp}`, constants.OAUTH_SECRET_KEY),
'x-lge-oauth-date': timestamp,
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
});
const token = res.data;
this.lgeapi_url = token.oauth2_backend_url || this.lgeapi_url;
return new Session(token.access_token, token.refresh_token, token.expires_in);
}
/**
* Retrieves the default headers for EMP requests.
*/
get defaultEmpHeaders() {
return {
'Accept': 'application/json',
'X-Application-Key': constants.APPLICATION_KEY,
'X-Client-App-Key': constants.CLIENT_ID,
'X-Lge-Svccode': 'SVC709',
'X-Device-Type': 'M01',
'X-Device-Platform': 'ADR',
'X-Device-Language-Type': 'IETF',
'X-Device-Publish-Flag': 'Y',
'X-Device-Country': this.gateway.country_code,
'X-Device-Language': this.gateway.language_code,
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Access-Control-Allow-Origin': '*',
'Accept-Encoding': 'gzip, deflate, br',
'Accept-Language': 'en-US,en;q=0.9',
};
}
/**
* Handles new terms and conditions that require user agreement.
*
* @param accessToken - The access token for the session.
*/
async handleNewTerm(accessToken) {
const showTermUrl = 'common/showTerms?callback_url=lgaccount.lgsmartthinq:/updateTerms'
+ '&country=VN&language=en-VN&division=ha:T20&terms_display_type=3&svc_list=SVC202';
const showTermHtml = await requestClient.get(this.gateway.login_base_url + showTermUrl, {
headers: {
'X-Login-Session': accessToken,
},
}).then(res => res.data);
const headers = {
...this.defaultEmpHeaders,
'X-Login-Session': accessToken,
'X-Signature': showTermHtml.match(/signature[\s]+:[\s]+"([^"]+)"/)[1],
'X-Timestamp': showTermHtml.match(/tStamp[\s]+:[\s]+"([^"]+)"/)[1],
};
const accountTermUrl = 'emp/v2.0/account/user/terms?opt_term_cond=001&term_data=SVC202&itg_terms_use_flag=Y&dummy_terms_use_flag=Y';
const accountTerms = (await requestClient.get(this.gateway.emp_base_url + accountTermUrl, { headers })
.then(res => {
return res.data.account?.terms;
}))
.map((term) => {
return term.termsID;
});
const termInfoUrl = 'emp/v2.0/info/terms?opt_term_cond=001&only_service_terms_flag=&itg_terms_use_flag=Y&term_data=SVC202';
const infoTerms = await requestClient.get(this.gateway.emp_base_url + termInfoUrl, { headers }).then(res => {
return res.data.info.terms;
});
const newTermAgreeNeeded = infoTerms.filter((term) => {
return accountTerms.indexOf(term.termsID) === -1;
}).map((term) => {
return [term.termsType, term.termsID, term.defaultLang].join(':');
}).join(',');
if (newTermAgreeNeeded) {
const updateAccountTermUrl = 'emp/v2.0/account/user/terms';
await requestClient.post(this.gateway.emp_base_url + updateAccountTermUrl, qs.stringify({ terms: newTermAgreeNeeded }), {
headers,
});
}
}
/**
* Retrieves the JSession ID for ThinQ v1 API compatibility.
*
* @param accessToken - The access token for the session.
* @returns A promise that resolves with the JSession ID.
*/
async getJSessionId(accessToken) {
// login to old gateway also - thinq v1
const memberLoginUrl = this.gateway.thinq1_url + 'member/login';
const memberLoginHeaders = {
'x-thinq-application-key': 'wideq',
'x-thinq-security-key': 'nuts_securitykey',
'Accept': 'application/json',
'x-thinq-token': accessToken,
};
const memberLoginData = {
countryCode: this.gateway.country_code,
langCode: this.gateway.language_code,
loginType: 'EMP',
token: accessToken,
};
return await requestClient.post(memberLoginUrl, { lgedmRoot: memberLoginData }, {
headers: memberLoginHeaders,
})
.then(res => res.data)
.then(data => data.lgedmRoot.jsessionId)
.catch(err => {
this.logger.debug(err.message.startsWith(ManualProcessNeededErrorCode)
? 'Please open the native LG App and sign in to your account to see what happened,'
+ ' maybe new agreement need your accept. Then try restarting Homebridge.'
: err.message);
this.logger.debug(err);
this.logger.info('Failed to login to old thinq v1 gateway. See debug logs for more details. Continuing anyways.');
});
}
/**
* Refreshes the access token using the refresh token.
*
* @param session - The current `Session` instance.
* @returns A promise that resolves with the updated `Session` instance.
*/
async refreshNewToken(session) {
try {
const gateway = await requestClient.post('https://kic.lgthinq.com:46030/api/common/gatewayUriList', {
lgedmRoot: {
countryCode: this.gateway.country_code,
langCode: this.gateway.language_code,
},
}, {
headers: {
'Accept': 'application/json',
'x-thinq-application-key': 'wideq',
'x-thinq-security-key': 'nuts_securitykey',
},
}).then(res => res.data.lgedmRoot);
this.lgeapi_url = gateway.oauthUri + '/';
}
catch (err) {
// ignore this error
}
const tokenUrl = this.lgeapi_url + 'oauth/1.0/oauth2/token';
const data = {
grant_type: 'refresh_token',
refresh_token: session.refreshToken,
};
const timestamp = DateTime.utc().toRFC2822();
const requestUrl = '/oauth/1.0/oauth2/token' + qs.stringify(data, { addQueryPrefix: true });
const signature = this.signature(`${requestUrl}\n${timestamp}`, constants.OAUTH_SECRET_KEY);
const headers = {
'x-lge-app-os': 'ADR',
'x-lge-appkey': constants.CLIENT_ID,
'x-lge-oauth-signature': signature,
'x-lge-oauth-date': timestamp,
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
};
const resp = await requestClient.post(tokenUrl, qs.stringify(data), { headers }).then(resp => resp.data);
session.newToken(resp.access_token, parseInt(resp.expires_in));
return session;
}
/**
* Retrieves the user's unique number from the LG API.
*
* @param accessToken - The access token for the session.
* @returns A promise that resolves with the user's unique number.
*/
async getUserNumber(accessToken) {
const profileUrl = this.lgeapi_url + 'users/profile';
const timestamp = DateTime.utc().toRFC2822();
const signature = this.signature(`/users/profile\n${timestamp}`, constants.OAUTH_SECRET_KEY);
const headers = {
'Accept': 'application/json',
'Authorization': 'Bearer ' + accessToken,
'X-Lge-Svccode': 'SVC202',
'X-Application-Key': constants.APPLICATION_KEY,
'lgemp-x-app-key': constants.CLIENT_ID,
'X-Device-Type': 'M01',
'X-Device-Platform': 'ADR',
'x-lge-oauth-date': timestamp,
'x-lge-oauth-signature': signature,
};
const resp = await requestClient.get(profileUrl, { headers }).then(resp => resp.data);
if (resp.status === 2) {
throw new AuthenticationError(resp.message);
}
return resp.account.userNo;
}
/**
* Constructs the login URL for the LG ThinQ API.
*
* @returns The login URL.
*/
async getLoginUrl() {
const params = {
country: this.gateway.country_code,
language: this.gateway.language_code,
client_id: constants.CLIENT_ID,
svc_list: constants.SVC_CODE,
svc_integrated: 'Y',
redirect_uri: 'lgaccount.lgsmartthinq:/',
show_thirdparty_login: 'LGE,MYLG,GGL,AMZ,FBK,APPL',
division: 'ha:T20',
callback_url: 'lgaccount.lgsmartthinq:/',
oauth2State: '12345',
show_select_country: 'N',
};
return this.gateway.login_base_url + 'login/signIn' + qs.stringify(params, { addQueryPrefix: true });
}
/**
* Generates a signature for API requests.
*
* @param message - The message to sign.
* @param secret - The secret key used for signing.
* @returns The generated signature.
*/
signature(message, secret) {
return crypto.createHmac('sha1', Buffer.from(secret)).update(message).digest('base64');
}
}
//# sourceMappingURL=Auth.js.map