Skip to content

Commit 6b79cf1

Browse files
committed
feat: device-auth first pass
1 parent 70892ae commit 6b79cf1

File tree

3 files changed

+134
-76
lines changed

3 files changed

+134
-76
lines changed

src/base.ts

+110-76
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type {
44
AirComfortDetailed,
55
BoilerSystemInformation,
66
Country,
7+
DeviceToken,
8+
DeviceVerification,
79
EnergyIQConsumptionDetails,
810
EnergyIQMeterReadings,
911
EnergyIQOverview,
@@ -28,106 +30,139 @@ import type {
2830
RunningTimesSummaryOnly,
2931
State,
3032
StatePresence,
33+
Token,
3134
User,
3235
Weather,
3336
} from "./types";
3437

3538
import { Agent } from "https";
3639
import axios, { Method } from "axios";
37-
import { AccessToken, ResourceOwnerPassword } from "simple-oauth2";
3840
import { TadoError } from "./types";
3941

40-
const EXPIRATION_WINDOW_IN_SECONDS = 300;
41-
42-
const tado_auth_url = "https://auth.tado.com";
4342
const tado_url = "https://my.tado.com";
44-
const tado_config = {
45-
client: {
46-
id: "tado-web-app",
47-
secret: "wZaRN7rpjn3FoNyF5IFuxg9uMzYJcvOoQ8QWiIqS3hfk6gLhVlG57j5YNoZL2Rtc",
48-
},
49-
auth: {
50-
tokenHost: tado_auth_url,
51-
},
52-
};
53-
54-
const client = new ResourceOwnerPassword(tado_config);
43+
const client_id = "1bb50063-6b0c-4d11-bd99-387f4a91cc46";
44+
const scope = "offline_access";
45+
const grant_type = "urn:ietf:params:oauth:grant-type:device_code";
5546

5647
export class BaseTado {
5748
#httpsAgent: Agent;
58-
#accessToken?: AccessToken | undefined;
59-
#username?: string;
60-
#password?: string;
49+
#token?: Token | undefined;
6150

62-
constructor(username?: string, password?: string) {
63-
this.#username = username;
64-
this.#password = password;
51+
constructor(refreshToken?: string) {
6552
this.#httpsAgent = new Agent({ keepAlive: true });
66-
}
6753

68-
async #login(): Promise<void> {
69-
if (!this.#username || !this.#password) {
70-
throw new Error("Please login before using Tado!");
54+
if (refreshToken) {
55+
this.#token = {
56+
access_token: "",
57+
refresh_token: refreshToken,
58+
expiry: new Date(0),
59+
};
7160
}
61+
}
7262

73-
const tokenParams = {
74-
username: this.#username,
75-
password: this.#password,
76-
scope: "home.user",
77-
};
63+
#parseDeviceToken(deviceToken: DeviceToken): Token {
64+
const expiry = new Date();
65+
expiry.setSeconds(expiry.getSeconds() + deviceToken.expires_in - 5); // Minus 5 seconds grace
7866

79-
this.#accessToken = await client.getToken(tokenParams);
67+
return {
68+
access_token: deviceToken.access_token,
69+
refresh_token: deviceToken.refresh_token,
70+
expiry,
71+
};
8072
}
8173

82-
/**
83-
* Refreshes the access token if it has expired or is about to expire.
84-
*
85-
* The method checks if an access token is available. If not, it attempts to login to obtain one.
86-
* If the token is within the expiration window, it tries to refresh the token.
87-
* In case of a failure during the refresh, it sets the token to null and attempts to login again.
88-
*
89-
* @returns A promise that resolves when the token has been refreshed or re-obtained.
90-
* @throws {@link TadoError} if no access token is available after attempting to login.
91-
*/
92-
async #refreshToken(): Promise<void> {
93-
if (!this.#accessToken) {
94-
await this.#login();
95-
}
74+
async #checkDevice(device_code: string, timeout: number): Promise<Token> {
75+
return new Promise((resolve, reject) => {
76+
setTimeout(() => {
77+
axios<DeviceToken>({
78+
url: "https://login.tado.com/oauth2/token",
79+
method: "POST",
80+
params: {
81+
client_id,
82+
device_code: device_code,
83+
grant_type,
84+
},
85+
})
86+
.then((resp) => {
87+
const deviceToken = resp.data;
88+
resolve(this.#parseDeviceToken(deviceToken));
89+
})
90+
.catch(reject);
91+
}, timeout);
92+
});
93+
}
9694

97-
if (!this.#accessToken) {
98-
throw new TadoError(`No access token available, even after login in.`);
99-
}
95+
async #deviceAuth(): Promise<Token> {
96+
const verify = await axios<DeviceVerification>({
97+
url: "https://login.tado.com/oauth2/device_authorize",
98+
method: "POST",
99+
params: {
100+
client_id,
101+
scope,
102+
},
103+
});
100104

101-
// If the start of the window has passed, refresh the token
102-
const shouldRefresh = this.#accessToken.expired(EXPIRATION_WINDOW_IN_SECONDS);
105+
console.log("------------------------------------------------");
106+
console.log("Device authentication required.");
107+
console.log("Please visit the following website in a browser.");
108+
console.log("");
109+
console.log(` ${verify.data.verification_uri_complete}`);
110+
console.log("");
111+
console.log(
112+
`Checks will occur every ${verify.data.interval}s up to a maximum of ${verify.data.expires_in}s`,
113+
);
114+
console.log("------------------------------------------------");
103115

104-
if (shouldRefresh) {
116+
// Wait for user to click buttons
117+
for (let i = 0; i < verify.data.expires_in; i += verify.data.interval) {
105118
try {
106-
this.#accessToken = await this.#accessToken.refresh();
107-
} catch (_error) {
108-
this.#accessToken = undefined;
109-
await this.#login();
119+
console.log("calling");
120+
const token = await this.#checkDevice(
121+
verify.data.device_code,
122+
verify.data.interval * 1000,
123+
);
124+
this.#token = token;
125+
return token;
126+
} catch {
127+
// Keep trying, we'll throw later
110128
}
111129
}
112-
}
113130

114-
get accessToken(): AccessToken | undefined {
115-
return this.#accessToken;
131+
throw new Error("Timeout waiting for user input");
116132
}
117133

118-
/**
119-
* Authenticates a user using the provided public client credentials, username and password.
120-
* For more information see
121-
* [https://support.tado.com/en/articles/8565472-how-do-i-update-my-rest-api-authentication-method-to-oauth-2](https://support.tado.com/en/articles/8565472-how-do-i-update-my-rest-api-authentication-method-to-oauth-2).
122-
*
123-
* @param username - The username of the user attempting to login.
124-
* @param password - The password of the user attempting to login.
125-
* @returns A promise that resolves when the login process is complete.
126-
*/
127-
async login(username: string, password: string): Promise<void> {
128-
this.#username = username;
129-
this.#password = password;
130-
await this.#login();
134+
async getToken(): Promise<Token> {
135+
if (!this.#token) {
136+
const token = await this.#deviceAuth();
137+
this.#token = token;
138+
return token;
139+
}
140+
141+
const now = new Date();
142+
143+
if (this.#token.expiry < now) {
144+
try {
145+
const resp = await axios<DeviceToken>({
146+
url: "https://login.tado.com/oauth2/token",
147+
method: "POST",
148+
params: {
149+
client_id,
150+
grant_type: "refresh_token",
151+
refresh_token: this.#token.refresh_token,
152+
},
153+
});
154+
155+
const token = this.#parseDeviceToken(resp.data);
156+
this.#token = token;
157+
return token;
158+
} catch {
159+
const token = await this.#deviceAuth();
160+
this.#token = token;
161+
return token;
162+
}
163+
} else {
164+
return this.#token;
165+
}
131166
}
132167

133168
/**
@@ -141,7 +176,7 @@ export class BaseTado {
141176
* @returns A promise that resolves to the response data.
142177
*/
143178
async apiCall<R, T = unknown>(url: string, method: Method = "get", data?: T): Promise<R> {
144-
await this.#refreshToken();
179+
const token = await this.getToken();
145180

146181
let callUrl = tado_url + url;
147182
if (url.includes("https")) {
@@ -152,16 +187,15 @@ export class BaseTado {
152187
method: method,
153188
data: data,
154189
headers: {
155-
Authorization: "Bearer " + this.#accessToken?.token.access_token,
190+
Authorization: `Bearer ${token.access_token}`,
156191
},
157192
httpsAgent: this.#httpsAgent,
158193
};
159194
if (method !== "get" && method !== "GET") {
160195
request.data = data;
161196
}
162-
const response = await axios(request);
163-
164-
return response.data as R;
197+
const response = await axios<R>(request);
198+
return response.data;
165199
}
166200

167201
/**

src/types/auth.ts

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export interface DeviceVerification {
2+
device_code: string;
3+
expires_in: number;
4+
interval: number;
5+
user_code: string;
6+
verification_uri: string;
7+
verification_uri_complete: string;
8+
}
9+
10+
export interface DeviceToken {
11+
access_token: string;
12+
expires_in: number;
13+
refresh_token: string;
14+
scope: string;
15+
token_type: string;
16+
userId: string;
17+
}
18+
19+
export interface Token {
20+
access_token: string;
21+
refresh_toke: string;
22+
expiry: Date;
23+
}

src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
ZoneType,
4040
} from "./enums";
4141

42+
export * from "./auth";
4243
export * from "./enums";
4344

4445
/**

0 commit comments

Comments
 (0)