matterbridge-lg-thinq-plugin 0.1.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/.claude/CLAUDE.md +43 -0
- package/.claude/agents/compiler.md +91 -0
- package/.claude/agents/direct-executor.md +98 -0
- package/.claude/agents/documenter.md +78 -0
- package/.claude/agents/finalizer.md +233 -0
- package/.claude/agents/implementer.md +112 -0
- package/.claude/agents/investigator.md +100 -0
- package/.claude/agents/release-manager.md +203 -0
- package/.claude/agents/reviewer.md +131 -0
- package/.claude/agents/technical-architect.md +403 -0
- package/.claude/agents/test-writer.md +159 -0
- package/.claude/agents/wiki-manager.md +147 -0
- package/.claude/instructions/agent-prompts.md +90 -0
- package/.claude/instructions/shared-rules.md +11 -0
- package/.claude/instructions/team-orchestrator-policy.md +165 -0
- package/.claude/memory.md +186 -0
- package/.claude/output-styles/engineer-manager.md +38 -0
- package/.claude/settings.json +40 -0
- package/.claude/settings.local.json +30 -0
- package/.claude/skills/load-policy/SKILL.md +13 -0
- package/.claude/skills/ref-idea/SKILL.md +44 -0
- package/.claude/skills/status-of/SKILL.md +38 -0
- package/.claude/skills/workspace-console/SKILL.md +49 -0
- package/.claude/statusline-command.sh +130 -0
- package/.claude/statusline.md +53 -0
- package/.claude/templates/approval-packet.template.html +171 -0
- package/.claude/templates/reference-workspaces.md +16 -0
- package/.claude/tools/cli-runner-server.cjs +220 -0
- package/.claude/tools/discord-send-server.cjs +188 -0
- package/.claude/tools/github-release-server.cjs +181 -0
- package/.claude/tools/glob-grep-server.cjs +267 -0
- package/.claude/tools/read-log-server.cjs +175 -0
- package/.github/codeql/codeql-config.yml +14 -0
- package/.github/workflows/build.yml +54 -0
- package/.github/workflows/codeql.yml +59 -0
- package/.github/workflows/coverage.yml +70 -0
- package/.github/workflows/publish.yml +103 -0
- package/.mcp.json +34 -0
- package/CHANGELOG.md +195 -0
- package/LICENSE +21 -0
- package/dist/cli/commands/devices.js +52 -0
- package/dist/cli/commands/energy.js +122 -0
- package/dist/cli/commands/login.js +54 -0
- package/dist/cli/help.js +50 -0
- package/dist/cli/main.js +56 -0
- package/dist/cli/session.js +13 -0
- package/dist/cli/types.js +1 -0
- package/dist/cli/utils.js +29 -0
- package/dist/cli.js +2 -0
- package/dist/constants/index.js +1 -0
- package/dist/core/domain/entities/ThinqDevice.js +43 -0
- package/dist/core/domain/value-objects/AirConditionerCapabilities.js +22 -0
- package/dist/core/domain/value-objects/ThinqFilterState.js +35 -0
- package/dist/core/domain/value-objects/ThinqSnapshot.js +125 -0
- package/dist/errors/AuthenticationError.js +26 -0
- package/dist/errors/BaseError.js +23 -0
- package/dist/errors/index.js +2 -0
- package/dist/model/LgThinkqPluginPlatformConfig.js +23 -0
- package/dist/model/LgThinqPluginPlatformConfig.js +23 -0
- package/dist/module.js +212 -0
- package/dist/platform/deviceRegistry.js +26 -0
- package/dist/platform/platformConfigManager.js +89 -0
- package/dist/platform/platformState.js +9 -0
- package/dist/platform/thinq/thinqAirConditionerCommandHandlers.js +190 -0
- package/dist/platform/thinq/thinqAirConditionerEndpointFactory.js +53 -0
- package/dist/platform/thinq/thinqAirConditionerFilterResetCommandHandler.js +54 -0
- package/dist/platform/thinq/thinqAirConditionerFilterStateSync.js +19 -0
- package/dist/platform/thinq/thinqAirConditionerStateSync.js +205 -0
- package/dist/platform/thinq/thinqDeviceConfigReconciler.js +26 -0
- package/dist/platform/thinq/thinqDeviceConfigurator.js +124 -0
- package/dist/platform/thinq/thinqDeviceDiscovery.js +33 -0
- package/dist/platform/thinq/thinqWasherCommandHandlers.js +54 -0
- package/dist/platform/thinq/thinqWasherEndpointFactory.js +6 -0
- package/dist/platform/thinq/thinqWasherStateSync.js +31 -0
- package/dist/platform/thinq/thinqWasherStopCommandResolver.js +38 -0
- package/dist/services/authentication/AccountAuthStrategy.js +135 -0
- package/dist/services/authentication/AuthContext.js +1 -0
- package/dist/services/authentication/AuthenticationCoordinator.js +29 -0
- package/dist/services/authentication/IAuthStrategy.js +1 -0
- package/dist/services/authentication/TokenAuthStrategy.js +27 -0
- package/dist/services/authentication/UserDataRepository.js +32 -0
- package/dist/services/thinq/gateway.js +61 -0
- package/dist/services/thinq/mqtt/mqttCertificate.js +63 -0
- package/dist/services/thinq/mqtt/mqttConnectionEvents.js +37 -0
- package/dist/services/thinq/mqtt/mqttKeyRepository.js +33 -0
- package/dist/services/thinq/mqtt/mqttRetry.js +25 -0
- package/dist/services/thinq/mqtt/thinqMqttListener.js +97 -0
- package/dist/services/thinq/serviceContainer.js +73 -0
- package/dist/services/thinq/session.js +48 -0
- package/dist/services/thinq/thinqApiClient.js +258 -0
- package/dist/services/thinq/thinqDeviceService.js +75 -0
- package/dist/services/thinq/thinqFilterMonitoringService.js +40 -0
- package/dist/settings.js +9 -0
- package/dist/tests/helpers/testUtils.js +87 -0
- package/dist/utils/mask.js +39 -0
- package/docs/README_CLI.md +166 -0
- package/eslint.config.js +194 -0
- package/matterbridge-lg-thinq-plugin.config.json +27 -0
- package/matterbridge-lg-thinq-plugin.schema.json +520 -0
- package/package.json +107 -0
- package/prettier.config.js +49 -0
- package/scripts/parse-junit-failures.mjs +45 -0
- package/scripts/parse-tsc-failures.mjs +48 -0
- package/scripts/run-build-local-summary.mjs +29 -0
- package/scripts/run-format-summary.mjs +36 -0
- package/scripts/run-lint-fix-summary.mjs +38 -0
- package/scripts/run-precommit-summary.mjs +94 -0
- package/src/cli/commands/devices.ts +67 -0
- package/src/cli/commands/energy.ts +160 -0
- package/src/cli/commands/login.ts +74 -0
- package/src/cli/help.ts +53 -0
- package/src/cli/main.ts +66 -0
- package/src/cli/session.ts +15 -0
- package/src/cli/types.ts +10 -0
- package/src/cli/utils.ts +30 -0
- package/src/cli.ts +3 -0
- package/src/constants/index.ts +8 -0
- package/src/core/domain/entities/ThinqDevice.test.ts +224 -0
- package/src/core/domain/entities/ThinqDevice.ts +74 -0
- package/src/core/domain/value-objects/AirConditionerCapabilities.ts +50 -0
- package/src/core/domain/value-objects/ThinqFilterState.ts +50 -0
- package/src/core/domain/value-objects/ThinqSnapshot.ts +162 -0
- package/src/errors/AuthenticationError.ts +37 -0
- package/src/errors/BaseError.ts +38 -0
- package/src/errors/index.ts +24 -0
- package/src/model/LgThinqPluginPlatformConfig.ts +94 -0
- package/src/module.ts +298 -0
- package/src/platform/deviceRegistry.ts +58 -0
- package/src/platform/platformConfigManager.ts +133 -0
- package/src/platform/platformState.ts +16 -0
- package/src/platform/thinq/thinqAirConditionerCommandHandlers.ts +291 -0
- package/src/platform/thinq/thinqAirConditionerEndpointFactory.ts +145 -0
- package/src/platform/thinq/thinqAirConditionerFilterResetCommandHandler.ts +96 -0
- package/src/platform/thinq/thinqAirConditionerFilterStateSync.ts +42 -0
- package/src/platform/thinq/thinqAirConditionerStateSync.ts +343 -0
- package/src/platform/thinq/thinqDeviceConfigReconciler.ts +42 -0
- package/src/platform/thinq/thinqDeviceConfigurator.test.ts +90 -0
- package/src/platform/thinq/thinqDeviceConfigurator.ts +191 -0
- package/src/platform/thinq/thinqDeviceDiscovery.test.ts +173 -0
- package/src/platform/thinq/thinqDeviceDiscovery.ts +51 -0
- package/src/platform/thinq/thinqWasherCommandHandlers.ts +120 -0
- package/src/platform/thinq/thinqWasherEndpointFactory.ts +37 -0
- package/src/platform/thinq/thinqWasherStateSync.ts +60 -0
- package/src/platform/thinq/thinqWasherStopCommandResolver.ts +62 -0
- package/src/services/authentication/AccountAuthStrategy.test.ts +358 -0
- package/src/services/authentication/AccountAuthStrategy.ts +209 -0
- package/src/services/authentication/AuthContext.ts +19 -0
- package/src/services/authentication/AuthenticationCoordinator.test.ts +217 -0
- package/src/services/authentication/AuthenticationCoordinator.ts +59 -0
- package/src/services/authentication/IAuthStrategy.ts +10 -0
- package/src/services/authentication/TokenAuthStrategy.test.ts +167 -0
- package/src/services/authentication/TokenAuthStrategy.ts +40 -0
- package/src/services/authentication/UserDataRepository.test.ts +149 -0
- package/src/services/authentication/UserDataRepository.ts +46 -0
- package/src/services/thinq/gateway.ts +86 -0
- package/src/services/thinq/mqtt/mqttCertificate.test.ts +344 -0
- package/src/services/thinq/mqtt/mqttCertificate.ts +102 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.test.ts +276 -0
- package/src/services/thinq/mqtt/mqttConnectionEvents.ts +76 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.test.ts +164 -0
- package/src/services/thinq/mqtt/mqttKeyRepository.ts +43 -0
- package/src/services/thinq/mqtt/mqttRetry.test.ts +239 -0
- package/src/services/thinq/mqtt/mqttRetry.ts +43 -0
- package/src/services/thinq/mqtt/thinqMqttListener.test.ts +349 -0
- package/src/services/thinq/mqtt/thinqMqttListener.ts +120 -0
- package/src/services/thinq/serviceContainer.test.ts +203 -0
- package/src/services/thinq/serviceContainer.ts +105 -0
- package/src/services/thinq/session.test.ts +287 -0
- package/src/services/thinq/session.ts +69 -0
- package/src/services/thinq/thinqApiClient.test.ts +702 -0
- package/src/services/thinq/thinqApiClient.ts +418 -0
- package/src/services/thinq/thinqDeviceService.test.ts +415 -0
- package/src/services/thinq/thinqDeviceService.ts +101 -0
- package/src/services/thinq/thinqFilterMonitoringService.test.ts +264 -0
- package/src/services/thinq/thinqFilterMonitoringService.ts +53 -0
- package/src/settings.ts +18 -0
- package/src/tests/cli/commands/devices.test.ts +293 -0
- package/src/tests/cli/commands/login.test.ts +240 -0
- package/src/tests/cli/help.test.ts +46 -0
- package/src/tests/cli/main.test.ts +214 -0
- package/src/tests/cli/session.test.ts +198 -0
- package/src/tests/cli/utils.test.ts +194 -0
- package/src/tests/core/domain/value-objects/AirConditionerCapabilities.test.ts +685 -0
- package/src/tests/core/domain/value-objects/ThinqFilterState.test.ts +210 -0
- package/src/tests/core/domain/value-objects/ThinqSnapshot.test.ts +1035 -0
- package/src/tests/helpers/testUtils.ts +118 -0
- package/src/tests/model/LgThinqPluginPlatformConfig.test.ts +81 -0
- package/src/tests/platform/deviceRegistry.test.ts +177 -0
- package/src/tests/platform/platformConfigManager.test.ts +790 -0
- package/src/tests/platform/platformState.test.ts +41 -0
- package/src/tests/platform/thinq/thinqAirConditionerCommandHandlers.test.ts +879 -0
- package/src/tests/platform/thinq/thinqAirConditionerEndpointFactory.test.ts +993 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterResetCommandHandler.test.ts +508 -0
- package/src/tests/platform/thinq/thinqAirConditionerFilterStateSync.test.ts +212 -0
- package/src/tests/platform/thinq/thinqAirConditionerStateSync.test.ts +1586 -0
- package/src/tests/platform/thinq/thinqDeviceConfigReconciler.test.ts +269 -0
- package/src/tests/platform/thinq/thinqDeviceConfigurator.test.ts +492 -0
- package/src/tests/platform/thinq/thinqWasherCommandHandlers.test.ts +594 -0
- package/src/tests/platform/thinq/thinqWasherEndpointFactory.test.ts +84 -0
- package/src/tests/platform/thinq/thinqWasherStateSync.test.ts +373 -0
- package/src/tests/platform/thinq/thinqWasherStopCommandResolver.test.ts +392 -0
- package/src/tests/settings.test.ts +33 -0
- package/src/utils/mask.ts +50 -0
- package/tsconfig.base.json +25 -0
- package/tsconfig.json +9 -0
- package/tsconfig.production.json +13 -0
- package/tsconfig.vitest.json +5 -0
- package/vite.config.ts +43 -0
- package/wiki/memory-archive.md +12 -0
- package/wiki/reference-workspaces.md +23 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export interface WasherStopCommandPayload {
|
|
2
|
+
readonly command?: string;
|
|
3
|
+
readonly dataKey?: string;
|
|
4
|
+
readonly dataValue?: unknown;
|
|
5
|
+
readonly dataSetList?: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
9
|
+
return typeof value === 'object' && value !== null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function toWasherStopCommandPayload(entry: Record<string, unknown>): WasherStopCommandPayload {
|
|
13
|
+
const payload: {
|
|
14
|
+
command?: string;
|
|
15
|
+
dataKey?: string;
|
|
16
|
+
dataValue?: unknown;
|
|
17
|
+
dataSetList?: Record<string, unknown>;
|
|
18
|
+
} = {};
|
|
19
|
+
|
|
20
|
+
if (typeof entry.command === 'string') {
|
|
21
|
+
payload.command = entry.command;
|
|
22
|
+
}
|
|
23
|
+
if (typeof entry.dataKey === 'string') {
|
|
24
|
+
payload.dataKey = entry.dataKey;
|
|
25
|
+
}
|
|
26
|
+
if ('dataValue' in entry) {
|
|
27
|
+
payload.dataValue = entry.dataValue;
|
|
28
|
+
}
|
|
29
|
+
if (isRecord(entry.dataSetList)) {
|
|
30
|
+
payload.dataSetList = entry.dataSetList;
|
|
31
|
+
} else if (isRecord(entry.data)) {
|
|
32
|
+
payload.dataSetList = entry.data;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return payload;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extracts a real `WMStop`/`WMOff` command payload from a device's own downloaded model JSON
|
|
40
|
+
* (`ControlWifi` slice). Pure, no I/O — returns `undefined` on any missing/malformed shape instead of
|
|
41
|
+
* throwing, so callers can treat "no usable command" as a normal (fail-closed) outcome.
|
|
42
|
+
*/
|
|
43
|
+
export function extractWasherStopCommand(deviceModel: Record<string, unknown>): WasherStopCommandPayload | undefined {
|
|
44
|
+
if (!isRecord(deviceModel)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const controlWifi = deviceModel.ControlWifi;
|
|
49
|
+
if (!isRecord(controlWifi)) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const stopEntry = isRecord(controlWifi.WMStop) ? controlWifi.WMStop : undefined;
|
|
54
|
+
const offEntry = isRecord(controlWifi.WMOff) ? controlWifi.WMOff : undefined;
|
|
55
|
+
const entry = stopEntry ?? offEntry;
|
|
56
|
+
|
|
57
|
+
if (!entry) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return toWasherStopCommandPayload(entry);
|
|
62
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import MockAdapter from 'axios-mock-adapter';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { AuthenticationError, InvalidCredentialsError } from '../../errors/index.js';
|
|
6
|
+
import { asPartial, buildGatewayResponse, createMockLogger } from '../../tests/helpers/testUtils.js';
|
|
7
|
+
import type { ThinqApiClient } from '../thinq/thinqApiClient.js';
|
|
8
|
+
import { AccountAuthStrategy } from './AccountAuthStrategy.js';
|
|
9
|
+
import type { ThinqAuthContext } from './AuthContext.js';
|
|
10
|
+
|
|
11
|
+
describe('AccountAuthStrategy', () => {
|
|
12
|
+
let mockAxios: MockAdapter;
|
|
13
|
+
let mockApiClient: ThinqApiClient;
|
|
14
|
+
let mockLogger: ReturnType<typeof createMockLogger>;
|
|
15
|
+
let strategy: AccountAuthStrategy;
|
|
16
|
+
|
|
17
|
+
const testContext: ThinqAuthContext = {
|
|
18
|
+
username: 'test@example.com',
|
|
19
|
+
password: 'testpassword123',
|
|
20
|
+
country: 'US',
|
|
21
|
+
language: 'en',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
mockAxios = new MockAdapter(axios as any);
|
|
28
|
+
mockLogger = createMockLogger();
|
|
29
|
+
mockApiClient = asPartial<ThinqApiClient>({
|
|
30
|
+
getGateway: vi.fn(),
|
|
31
|
+
});
|
|
32
|
+
strategy = new AccountAuthStrategy(mockApiClient, mockLogger);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
mockAxios.reset();
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('authenticate', () => {
|
|
41
|
+
it('should throw InvalidCredentialsError when username is missing', async () => {
|
|
42
|
+
const contextWithoutUsername = asPartial<ThinqAuthContext>({
|
|
43
|
+
password: 'password',
|
|
44
|
+
country: 'US',
|
|
45
|
+
language: 'en',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await expect(strategy.authenticate(contextWithoutUsername)).rejects.toThrow(InvalidCredentialsError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should throw InvalidCredentialsError when password is missing', async () => {
|
|
52
|
+
const contextWithoutPassword = asPartial<ThinqAuthContext>({
|
|
53
|
+
username: 'test@example.com',
|
|
54
|
+
country: 'US',
|
|
55
|
+
language: 'en',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await expect(strategy.authenticate(contextWithoutPassword)).rejects.toThrow(InvalidCredentialsError);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should throw InvalidCredentialsError when both username and password are missing', async () => {
|
|
62
|
+
const emptyContext = asPartial<ThinqAuthContext>({
|
|
63
|
+
country: 'US',
|
|
64
|
+
language: 'en',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await expect(strategy.authenticate(emptyContext)).rejects.toThrow(InvalidCredentialsError);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw InvalidCredentialsError on preLogin 4xx response', async () => {
|
|
71
|
+
const gatewayData = buildGatewayResponse();
|
|
72
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
73
|
+
|
|
74
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(401);
|
|
75
|
+
|
|
76
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(InvalidCredentialsError);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should throw AuthenticationError on preLogin non-4xx failure', async () => {
|
|
80
|
+
const gatewayData = buildGatewayResponse();
|
|
81
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
82
|
+
|
|
83
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(500);
|
|
84
|
+
|
|
85
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should throw InvalidCredentialsError on account/session 4xx response', async () => {
|
|
89
|
+
const gatewayData = buildGatewayResponse();
|
|
90
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
91
|
+
|
|
92
|
+
const preLoginResponse = {
|
|
93
|
+
signature: 'test-signature',
|
|
94
|
+
tStamp: '1234567890',
|
|
95
|
+
encrypted_pw: 'encrypted-password',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
99
|
+
mockAxios
|
|
100
|
+
.onPost(
|
|
101
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
102
|
+
)
|
|
103
|
+
.reply(403);
|
|
104
|
+
|
|
105
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(InvalidCredentialsError);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should throw AuthenticationError on account/session non-4xx failure', async () => {
|
|
109
|
+
const gatewayData = buildGatewayResponse();
|
|
110
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
111
|
+
|
|
112
|
+
const preLoginResponse = {
|
|
113
|
+
signature: 'test-signature',
|
|
114
|
+
tStamp: '1234567890',
|
|
115
|
+
encrypted_pw: 'encrypted-password',
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
119
|
+
mockAxios
|
|
120
|
+
.onPost(
|
|
121
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
122
|
+
)
|
|
123
|
+
.reply(500);
|
|
124
|
+
|
|
125
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should throw AuthenticationError on OAuth key lookup failure', async () => {
|
|
129
|
+
const gatewayData = buildGatewayResponse();
|
|
130
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
131
|
+
|
|
132
|
+
const preLoginResponse = {
|
|
133
|
+
signature: 'test-signature',
|
|
134
|
+
tStamp: '1234567890',
|
|
135
|
+
encrypted_pw: 'encrypted-password',
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const accountResponse = {
|
|
139
|
+
account: {
|
|
140
|
+
userIDType: 'LGE',
|
|
141
|
+
country: 'US',
|
|
142
|
+
userID: 'test@example.com',
|
|
143
|
+
loginSessionID: 'session-id',
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
148
|
+
mockAxios
|
|
149
|
+
.onPost(
|
|
150
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
151
|
+
)
|
|
152
|
+
.reply(200, accountResponse);
|
|
153
|
+
mockAxios.onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`).reply(500);
|
|
154
|
+
|
|
155
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should throw AuthenticationError on EMP authorization failure when status is not 1', async () => {
|
|
159
|
+
const gatewayData = buildGatewayResponse();
|
|
160
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
161
|
+
|
|
162
|
+
const preLoginResponse = {
|
|
163
|
+
signature: 'test-signature',
|
|
164
|
+
tStamp: '1234567890',
|
|
165
|
+
encrypted_pw: 'encrypted-password',
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const accountResponse = {
|
|
169
|
+
account: {
|
|
170
|
+
userIDType: 'LGE',
|
|
171
|
+
country: 'US',
|
|
172
|
+
userID: 'test@example.com',
|
|
173
|
+
loginSessionID: 'session-id',
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const secretKeyResponse = {
|
|
178
|
+
returnData: 'secret-key-data',
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
182
|
+
mockAxios
|
|
183
|
+
.onPost(
|
|
184
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
185
|
+
)
|
|
186
|
+
.reply(200, accountResponse);
|
|
187
|
+
mockAxios
|
|
188
|
+
.onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
|
|
189
|
+
.reply(200, secretKeyResponse);
|
|
190
|
+
mockAxios
|
|
191
|
+
.onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*'))
|
|
192
|
+
.reply(200, { status: 0, message: 'Auth failed', redirect_uri: '' });
|
|
193
|
+
|
|
194
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should throw AuthenticationError on EMP authorization network failure', async () => {
|
|
198
|
+
const gatewayData = buildGatewayResponse();
|
|
199
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
200
|
+
|
|
201
|
+
const preLoginResponse = {
|
|
202
|
+
signature: 'test-signature',
|
|
203
|
+
tStamp: '1234567890',
|
|
204
|
+
encrypted_pw: 'encrypted-password',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const accountResponse = {
|
|
208
|
+
account: {
|
|
209
|
+
userIDType: 'LGE',
|
|
210
|
+
country: 'US',
|
|
211
|
+
userID: 'test@example.com',
|
|
212
|
+
loginSessionID: 'session-id',
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const secretKeyResponse = {
|
|
217
|
+
returnData: 'secret-key-data',
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
221
|
+
mockAxios
|
|
222
|
+
.onPost(
|
|
223
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
224
|
+
)
|
|
225
|
+
.reply(200, accountResponse);
|
|
226
|
+
mockAxios
|
|
227
|
+
.onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
|
|
228
|
+
.reply(200, secretKeyResponse);
|
|
229
|
+
mockAxios.onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*')).reply(500);
|
|
230
|
+
|
|
231
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('should throw AuthenticationError on token exchange failure', async () => {
|
|
235
|
+
const gatewayData = buildGatewayResponse();
|
|
236
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
237
|
+
|
|
238
|
+
const preLoginResponse = {
|
|
239
|
+
signature: 'test-signature',
|
|
240
|
+
tStamp: '1234567890',
|
|
241
|
+
encrypted_pw: 'encrypted-password',
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const accountResponse = {
|
|
245
|
+
account: {
|
|
246
|
+
userIDType: 'LGE',
|
|
247
|
+
country: 'US',
|
|
248
|
+
userID: 'test@example.com',
|
|
249
|
+
loginSessionID: 'session-id',
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const secretKeyResponse = {
|
|
254
|
+
returnData: 'secret-key-data',
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const empResponse = {
|
|
258
|
+
status: 1,
|
|
259
|
+
redirect_uri: 'lgaccount.lgsmartthinq:/?code=test-code&oauth2_backend_url=https://test.com/',
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
263
|
+
mockAxios
|
|
264
|
+
.onPost(
|
|
265
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
266
|
+
)
|
|
267
|
+
.reply(200, accountResponse);
|
|
268
|
+
mockAxios
|
|
269
|
+
.onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
|
|
270
|
+
.reply(200, secretKeyResponse);
|
|
271
|
+
mockAxios
|
|
272
|
+
.onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*'))
|
|
273
|
+
.reply(200, empResponse);
|
|
274
|
+
mockAxios.onPost(new RegExp('https://test.com/oauth/1.0/oauth2/token')).reply(500);
|
|
275
|
+
|
|
276
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow(AuthenticationError);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should return ThinqUserData on successful authentication', async () => {
|
|
280
|
+
const gatewayData = buildGatewayResponse();
|
|
281
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
282
|
+
|
|
283
|
+
const preLoginResponse = {
|
|
284
|
+
signature: 'test-signature',
|
|
285
|
+
tStamp: '1234567890',
|
|
286
|
+
encrypted_pw: 'encrypted-password',
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const accountResponse = {
|
|
290
|
+
account: {
|
|
291
|
+
userIDType: 'LGE',
|
|
292
|
+
country: 'US',
|
|
293
|
+
userID: 'test@example.com',
|
|
294
|
+
loginSessionID: 'session-id',
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const secretKeyResponse = {
|
|
299
|
+
returnData: 'secret-key-data',
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
const empResponse = {
|
|
303
|
+
status: 1,
|
|
304
|
+
redirect_uri: 'lgaccount.lgsmartthinq:/?code=test-code&oauth2_backend_url=https://test.com/',
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const tokenResponse = {
|
|
308
|
+
access_token: 'access-token-123',
|
|
309
|
+
refresh_token: 'refresh-token-123',
|
|
310
|
+
expires_in: '3600',
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
314
|
+
mockAxios
|
|
315
|
+
.onPost(
|
|
316
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
317
|
+
)
|
|
318
|
+
.reply(200, accountResponse);
|
|
319
|
+
mockAxios
|
|
320
|
+
.onGet(`${gatewayData.empSpxUri}/searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`)
|
|
321
|
+
.reply(200, secretKeyResponse);
|
|
322
|
+
mockAxios
|
|
323
|
+
.onGet(new RegExp('https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession.*'))
|
|
324
|
+
.reply(200, empResponse);
|
|
325
|
+
mockAxios.onPost(new RegExp('https://test.com/oauth/1.0/oauth2/token')).reply(200, tokenResponse);
|
|
326
|
+
|
|
327
|
+
const result = await strategy.authenticate(testContext);
|
|
328
|
+
|
|
329
|
+
expect(result).toBeDefined();
|
|
330
|
+
expect(result?.accessToken).toBe('access-token-123');
|
|
331
|
+
expect(result?.refreshToken).toBe('refresh-token-123');
|
|
332
|
+
expect(result?.country).toBe('US');
|
|
333
|
+
expect(result?.language).toBe('en');
|
|
334
|
+
expect(result?.expiresAtEpochSeconds).toBeGreaterThan(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should log authentication start message', async () => {
|
|
338
|
+
const gatewayData = buildGatewayResponse();
|
|
339
|
+
vi.mocked(mockApiClient.getGateway).mockResolvedValue(gatewayData);
|
|
340
|
+
|
|
341
|
+
const preLoginResponse = {
|
|
342
|
+
signature: 'test-signature',
|
|
343
|
+
tStamp: '1234567890',
|
|
344
|
+
encrypted_pw: 'encrypted-password',
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
mockAxios.onPost(`${gatewayData.empSpxUri}/preLogin`).reply(200, preLoginResponse);
|
|
348
|
+
mockAxios
|
|
349
|
+
.onPost(
|
|
350
|
+
new RegExp(`${gatewayData.empTermsUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/emp/v2.0/account/session/.*`),
|
|
351
|
+
)
|
|
352
|
+
.reply(401);
|
|
353
|
+
|
|
354
|
+
await expect(strategy.authenticate(testContext)).rejects.toThrow();
|
|
355
|
+
expect(mockLogger.notice).toHaveBeenCalledWith('Authenticating with LG ThinQ account credentials...');
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import { AnsiLogger } from 'matterbridge/logger';
|
|
5
|
+
|
|
6
|
+
import { AuthenticationError, InvalidCredentialsError } from '../../errors/index.js';
|
|
7
|
+
import {
|
|
8
|
+
buildEmpDefaultHeaders,
|
|
9
|
+
formatRfc2822Utc,
|
|
10
|
+
Gateway,
|
|
11
|
+
signThinqMessage,
|
|
12
|
+
THINQ_CLIENT_ID,
|
|
13
|
+
THINQ_OAUTH_CLIENT_KEY,
|
|
14
|
+
THINQ_OAUTH_SECRET_KEY,
|
|
15
|
+
} from '../thinq/gateway.js';
|
|
16
|
+
import { ThinqSession } from '../thinq/session.js';
|
|
17
|
+
import { ThinqApiClient } from '../thinq/thinqApiClient.js';
|
|
18
|
+
import type { ThinqAuthContext, ThinqUserData } from './AuthContext.js';
|
|
19
|
+
import type { IAuthStrategy } from './IAuthStrategy.js';
|
|
20
|
+
|
|
21
|
+
interface PreLoginResponse {
|
|
22
|
+
signature: string;
|
|
23
|
+
tStamp: string;
|
|
24
|
+
encrypted_pw: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AccountSessionResponse {
|
|
28
|
+
userIDType: string;
|
|
29
|
+
country: string;
|
|
30
|
+
userID: string;
|
|
31
|
+
loginSessionID: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface EmpAuthorizeResponse {
|
|
35
|
+
status: number;
|
|
36
|
+
message?: string;
|
|
37
|
+
redirect_uri: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface TokenResponse {
|
|
41
|
+
access_token: string;
|
|
42
|
+
refresh_token: string;
|
|
43
|
+
expires_in: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const EMP_REDIRECT_URI = 'lgaccount.lgsmartthinq:/';
|
|
47
|
+
|
|
48
|
+
/** True when `err` is an Axios error with a 4xx HTTP status (i.e. a real bad-credentials/rejected-request response). */
|
|
49
|
+
function isClientErrorResponse(err: unknown): boolean {
|
|
50
|
+
if (!axios.isAxiosError(err)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const status = err.response?.status;
|
|
55
|
+
|
|
56
|
+
return status !== undefined && status >= 400 && status < 500;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Account (username/password) authentication strategy. Ports the 5-call EMP login chain
|
|
61
|
+
* (`Auth.login`→`loginStep2`, `homebridge-lg-thinq/src/lib/Auth.ts:99-229`): SHA-512 password hash,
|
|
62
|
+
* preLogin, EMP session, OAuth secret-key lookup, HMAC-SHA1-signed empsession authorize, then oauth2 token exchange.
|
|
63
|
+
*/
|
|
64
|
+
export class AccountAuthStrategy implements IAuthStrategy {
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly apiClient: ThinqApiClient,
|
|
67
|
+
private readonly logger: AnsiLogger,
|
|
68
|
+
) {}
|
|
69
|
+
|
|
70
|
+
public async authenticate(context: ThinqAuthContext): Promise<ThinqUserData | undefined> {
|
|
71
|
+
if (!context.username || !context.password) {
|
|
72
|
+
throw new InvalidCredentialsError(context.username);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
this.logger.notice('Authenticating with LG ThinQ account credentials...');
|
|
76
|
+
|
|
77
|
+
const gatewayData = await this.apiClient.getGateway();
|
|
78
|
+
const gateway = new Gateway(gatewayData);
|
|
79
|
+
const hashedPassword = crypto.createHash('sha512').update(context.password).digest('hex');
|
|
80
|
+
|
|
81
|
+
return this.loginStep2(gateway, context.username, hashedPassword, context.country, context.language);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async loginStep2(
|
|
85
|
+
gateway: Gateway,
|
|
86
|
+
username: string,
|
|
87
|
+
encryptedPassword: string,
|
|
88
|
+
country: string,
|
|
89
|
+
language: string,
|
|
90
|
+
): Promise<ThinqUserData> {
|
|
91
|
+
const headers: Record<string, string> = { ...buildEmpDefaultHeaders(gateway) };
|
|
92
|
+
|
|
93
|
+
const preLoginData = new URLSearchParams({
|
|
94
|
+
user_auth2: encryptedPassword,
|
|
95
|
+
log_param: `login request / user_id : ${username} / third_party : null / svc_list : SVC202,SVC710 / 3rd_service : `,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const preLogin = await axios
|
|
99
|
+
.post<PreLoginResponse>(`${gateway.loginBaseUrl}preLogin`, preLoginData.toString(), { headers })
|
|
100
|
+
.then((res) => res.data)
|
|
101
|
+
.catch((err: unknown) => {
|
|
102
|
+
if (isClientErrorResponse(err)) {
|
|
103
|
+
throw new InvalidCredentialsError(username);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new AuthenticationError('LG pre-login failed.', { cause: err });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
headers['X-Signature'] = preLogin.signature;
|
|
110
|
+
headers['X-Timestamp'] = preLogin.tStamp;
|
|
111
|
+
|
|
112
|
+
const sessionData = new URLSearchParams({
|
|
113
|
+
user_auth2: preLogin.encrypted_pw,
|
|
114
|
+
password_hash_prameter_flag: 'Y',
|
|
115
|
+
svc_list: 'SVC202,SVC710',
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const loginUrl = `${gateway.empBaseUrl}emp/v2.0/account/session/${encodeURIComponent(username)}`;
|
|
119
|
+
const account = await axios
|
|
120
|
+
.post<{ account: AccountSessionResponse }>(loginUrl, sessionData.toString(), { headers })
|
|
121
|
+
.then((res) => res.data.account)
|
|
122
|
+
.catch((err: unknown) => {
|
|
123
|
+
if (isClientErrorResponse(err)) {
|
|
124
|
+
throw new InvalidCredentialsError(username);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new AuthenticationError('LG account login failed.', { cause: err });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const secretKeyUrl = `${gateway.loginBaseUrl}searchKey?key_name=OAUTH_SECRETKEY&sever_type=OP`;
|
|
131
|
+
const secretKey = await axios
|
|
132
|
+
.get<{ returnData: string }>(secretKeyUrl)
|
|
133
|
+
.then((res) => res.data.returnData)
|
|
134
|
+
.catch((err: unknown) => {
|
|
135
|
+
throw new AuthenticationError('LG OAuth key lookup failed.', { cause: err });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const timestamp = formatRfc2822Utc(new Date());
|
|
139
|
+
const empParams = new URLSearchParams({
|
|
140
|
+
account_type: account.userIDType,
|
|
141
|
+
client_id: THINQ_CLIENT_ID,
|
|
142
|
+
country_code: account.country,
|
|
143
|
+
redirect_uri: EMP_REDIRECT_URI,
|
|
144
|
+
response_type: 'code',
|
|
145
|
+
state: '12345',
|
|
146
|
+
username: account.userID,
|
|
147
|
+
});
|
|
148
|
+
const empUrl = new URL(`https://emp-oauth.lgecloud.com/emp/oauth2/authorize/empsession?${empParams.toString()}`);
|
|
149
|
+
const empSignature = signThinqMessage(`${empUrl.pathname}${empUrl.search}\n${timestamp}`, secretKey);
|
|
150
|
+
|
|
151
|
+
const authorize = await axios
|
|
152
|
+
.get<EmpAuthorizeResponse>(empUrl.href, {
|
|
153
|
+
headers: {
|
|
154
|
+
'lgemp-x-app-key': THINQ_OAUTH_CLIENT_KEY,
|
|
155
|
+
'lgemp-x-date': timestamp,
|
|
156
|
+
'lgemp-x-session-key': account.loginSessionID,
|
|
157
|
+
'lgemp-x-signature': empSignature,
|
|
158
|
+
Accept: 'application/json',
|
|
159
|
+
'X-Device-Type': 'M01',
|
|
160
|
+
'X-Device-Platform': 'ADR',
|
|
161
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
.then((res) => res.data)
|
|
165
|
+
.catch((err: unknown) => {
|
|
166
|
+
throw new AuthenticationError('LG OAuth authorization failed.', { cause: err });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
if (authorize.status !== 1) {
|
|
170
|
+
throw new AuthenticationError(authorize.message ?? 'LG OAuth authorization was rejected.');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const redirectUri = new URL(authorize.redirect_uri);
|
|
174
|
+
const code = redirectUri.searchParams.get('code') ?? '';
|
|
175
|
+
const oauth2BackendUrl = redirectUri.searchParams.get('oauth2_backend_url') ?? gateway.loginBaseUrl;
|
|
176
|
+
|
|
177
|
+
const tokenData = new URLSearchParams({
|
|
178
|
+
code,
|
|
179
|
+
grant_type: 'authorization_code',
|
|
180
|
+
redirect_uri: EMP_REDIRECT_URI,
|
|
181
|
+
});
|
|
182
|
+
const requestPath = `/oauth/1.0/oauth2/token?${tokenData.toString()}`;
|
|
183
|
+
const tokenTimestamp = formatRfc2822Utc(new Date());
|
|
184
|
+
|
|
185
|
+
const token = await axios
|
|
186
|
+
.post<TokenResponse>(`${oauth2BackendUrl}oauth/1.0/oauth2/token`, tokenData.toString(), {
|
|
187
|
+
headers: {
|
|
188
|
+
'x-lge-app-os': 'ADR',
|
|
189
|
+
'x-lge-appkey': THINQ_CLIENT_ID,
|
|
190
|
+
'x-lge-oauth-signature': signThinqMessage(`${requestPath}\n${tokenTimestamp}`, THINQ_OAUTH_SECRET_KEY),
|
|
191
|
+
'x-lge-oauth-date': tokenTimestamp,
|
|
192
|
+
Accept: 'application/json',
|
|
193
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
.then((res) => res.data)
|
|
197
|
+
.catch((err: unknown) => {
|
|
198
|
+
throw new AuthenticationError('LG token exchange failed.', { cause: err });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
accessToken: token.access_token,
|
|
203
|
+
refreshToken: token.refresh_token,
|
|
204
|
+
expiresAtEpochSeconds: ThinqSession.expiryFromExpiresIn(Number.parseInt(token.expires_in, 10)),
|
|
205
|
+
country,
|
|
206
|
+
language,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Context object passed to ThinQ authentication strategies. */
|
|
2
|
+
export interface ThinqAuthContext {
|
|
3
|
+
readonly username?: string;
|
|
4
|
+
readonly password?: string;
|
|
5
|
+
readonly refreshToken?: string;
|
|
6
|
+
readonly country: string;
|
|
7
|
+
readonly language: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Result of a successful ThinQ authentication, persisted across restarts. */
|
|
11
|
+
export interface ThinqUserData {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
refreshToken: string;
|
|
14
|
+
expiresAtEpochSeconds: number;
|
|
15
|
+
country: string;
|
|
16
|
+
language: string;
|
|
17
|
+
/** LG's numeric account profile ID (`userNo`), fetched post-login and required for `x-user-no`/`x-client-id`. */
|
|
18
|
+
userNumber?: string;
|
|
19
|
+
}
|