ring-client-api 11.7.5 → 11.7.6
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/README.md +4 -0
- package/lib/api.js +8 -3
- package/lib/rest-client.d.ts +1 -0
- package/lib/rest-client.js +20 -1
- package/lib/test/rest-client.spec.d.ts +1 -0
- package/lib/test/rest-client.spec.js +237 -0
- package/lib/util.d.ts +1 -0
- package/lib/util.js +13 -2
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ the [Ring Alarm System](https://shop.ring.com/pages/security-system),
|
|
|
11
11
|
and third party devices that connect to the Ring Alarm System.
|
|
12
12
|
Built to support the [homebridge-ring Plugin](../homebridge-ring)
|
|
13
13
|
|
|
14
|
+
## Troubleshooting Issues
|
|
15
|
+
|
|
16
|
+
If you are having issues, please look for related articles in the [wiki](https://github.com/dgreif/ring/wiki) and search existing [Issues](https://github.com/dgreif/ring/issues) before opening a new Issue/Discussion
|
|
17
|
+
|
|
14
18
|
## Installation
|
|
15
19
|
|
|
16
20
|
`npm i ring-client-api`
|
package/lib/api.js
CHANGED
|
@@ -127,14 +127,18 @@ class RingApi extends subscribed_1.Subscribed {
|
|
|
127
127
|
senderId: '876313859327', // for Ring android app. 703521446232 for ring-site
|
|
128
128
|
}), devicesById = {}, sendToDevice = (id, notification) => {
|
|
129
129
|
devicesById[id]?.processPushNotification(notification);
|
|
130
|
-
};
|
|
130
|
+
}, onPushNotificationToken = new rxjs_1.Subject();
|
|
131
131
|
for (const camera of cameras) {
|
|
132
132
|
devicesById[camera.id] = camera;
|
|
133
133
|
}
|
|
134
134
|
for (const intercom of intercoms) {
|
|
135
135
|
devicesById[intercom.id] = intercom;
|
|
136
136
|
}
|
|
137
|
-
pushReceiver.onCredentialsChanged(
|
|
137
|
+
pushReceiver.onCredentialsChanged(({ newCredentials: { fcm: { token }, }, }) => onPushNotificationToken.next(token));
|
|
138
|
+
this.addSubscriptions((0, rxjs_1.combineLatest)([
|
|
139
|
+
onPushNotificationToken,
|
|
140
|
+
this.restClient.onSession, // combined but not used here, just to trigger another request when session is updated
|
|
141
|
+
]).subscribe(async ([token]) => {
|
|
138
142
|
try {
|
|
139
143
|
await this.restClient.request({
|
|
140
144
|
url: (0, rest_client_1.clientApi)('device'),
|
|
@@ -154,7 +158,7 @@ class RingApi extends subscribed_1.Subscribed {
|
|
|
154
158
|
catch (e) {
|
|
155
159
|
(0, util_1.logError)(e);
|
|
156
160
|
}
|
|
157
|
-
});
|
|
161
|
+
}));
|
|
158
162
|
pushReceiver.onNotification(({ message }) => {
|
|
159
163
|
const dataJson = message.data?.gcmData;
|
|
160
164
|
try {
|
|
@@ -236,6 +240,7 @@ class RingApi extends subscribed_1.Subscribed {
|
|
|
236
240
|
(0, util_1.logError)(e);
|
|
237
241
|
});
|
|
238
242
|
this.restClient.clearTimeouts();
|
|
243
|
+
(0, util_1.clearTimeouts)();
|
|
239
244
|
}
|
|
240
245
|
}
|
|
241
246
|
exports.RingApi = RingApi;
|
package/lib/rest-client.d.ts
CHANGED
package/lib/rest-client.js
CHANGED
|
@@ -94,6 +94,7 @@ class RingRestClient {
|
|
|
94
94
|
this.sessionPromise = undefined;
|
|
95
95
|
this.using2fa = false;
|
|
96
96
|
this.onRefreshTokenUpdated = new rxjs_1.ReplaySubject(1);
|
|
97
|
+
this.onSession = new rxjs_1.ReplaySubject(1);
|
|
97
98
|
this.baseSessionMetadata = {
|
|
98
99
|
api_version: apiVersion,
|
|
99
100
|
device_model: this.authOptions.controlCenterDisplayName ?? 'ring-client-api',
|
|
@@ -161,6 +162,10 @@ class RingRestClient {
|
|
|
161
162
|
responseError.startsWith('Verification Code')) // invalid 2fa code entered
|
|
162
163
|
) {
|
|
163
164
|
this.using2fa = true;
|
|
165
|
+
if (response.statusCode === 400) {
|
|
166
|
+
this.promptFor2fa = 'Invalid 2fa code entered. Please try again.';
|
|
167
|
+
throw new Error(responseError);
|
|
168
|
+
}
|
|
164
169
|
if ('tsv_state' in responseData) {
|
|
165
170
|
const { tsv_state, phone } = responseData, prompt = tsv_state === 'totp'
|
|
166
171
|
? 'from your authenticator app'
|
|
@@ -205,7 +210,9 @@ class RingRestClient {
|
|
|
205
210
|
getSession() {
|
|
206
211
|
return this.authPromise.then(async (authToken) => {
|
|
207
212
|
try {
|
|
208
|
-
|
|
213
|
+
const session = await this.fetchNewSession(authToken);
|
|
214
|
+
this.onSession.next(session);
|
|
215
|
+
return session;
|
|
209
216
|
}
|
|
210
217
|
catch (e) {
|
|
211
218
|
const response = e.response || {};
|
|
@@ -232,8 +239,20 @@ class RingRestClient {
|
|
|
232
239
|
}
|
|
233
240
|
refreshSession() {
|
|
234
241
|
this.sessionPromise = this.getSession();
|
|
242
|
+
this.sessionPromise.finally(() => {
|
|
243
|
+
// Refresh the session every 12 hours
|
|
244
|
+
// This is needed to keep the session alive for users outside the US, due to Data Residency laws
|
|
245
|
+
// We believe Ring is clearing the session info after ~24 hours, which breaks Push Notifications
|
|
246
|
+
const timeout = setTimeout(() => {
|
|
247
|
+
this.refreshSession();
|
|
248
|
+
}, 12 * 60 * 60 * 1000); // 12 hours
|
|
249
|
+
this.timeouts.push(timeout);
|
|
250
|
+
});
|
|
235
251
|
}
|
|
236
252
|
async request(options) {
|
|
253
|
+
if (!this.sessionPromise) {
|
|
254
|
+
this.refreshSession();
|
|
255
|
+
}
|
|
237
256
|
const hardwareId = await this.hardwareIdPromise, url = options.url, initialSessionPromise = this.sessionPromise;
|
|
238
257
|
try {
|
|
239
258
|
await initialSessionPromise;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const msw_1 = require("msw");
|
|
4
|
+
const node_1 = require("msw/node");
|
|
5
|
+
const rest_client_1 = require("../rest-client");
|
|
6
|
+
const util_1 = require("../util");
|
|
7
|
+
const rxjs_1 = require("rxjs");
|
|
8
|
+
let sessionCreatedCount = 0, client;
|
|
9
|
+
const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoFactorAuthCode = '123456', hardwareIdPromise = (0, util_1.getHardwareId)(), accessToken = 'ey__accees_token', secondAccessToken = 'ey__second_accees_token', refreshToken = 'ey__refresh_token', secondRefreshToken = 'ey__second_refresh_token', thirdRefreshToken = 'ey__third_refresh_token', server = (0, node_1.setupServer)(msw_1.rest.post('https://oauth.ring.com/oauth/token', async (req, res, ctx) => {
|
|
10
|
+
const body = await req.json();
|
|
11
|
+
if (req.headers.get('2fa-support') !== 'true' ||
|
|
12
|
+
req.headers.get('User-Agent') !== 'android:com.ringapp' ||
|
|
13
|
+
req.headers.get('hardware_id') !== (await hardwareIdPromise)) {
|
|
14
|
+
return res(ctx.status(400), ctx.json({
|
|
15
|
+
code: 1,
|
|
16
|
+
error: 'Invalid auth headers: ' +
|
|
17
|
+
JSON.stringify(req.headers.raw(), null, 2),
|
|
18
|
+
}));
|
|
19
|
+
}
|
|
20
|
+
if (body.grant_type === 'refresh_token') {
|
|
21
|
+
if (body.refresh_token === refreshToken) {
|
|
22
|
+
// Valid refresh token
|
|
23
|
+
return res(ctx.status(200), ctx.json({
|
|
24
|
+
access_token: accessToken,
|
|
25
|
+
expires_in: 3600,
|
|
26
|
+
refresh_token: secondRefreshToken,
|
|
27
|
+
scope: 'client',
|
|
28
|
+
token_type: 'Bearer',
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
if (body.refresh_token === secondRefreshToken) {
|
|
32
|
+
// Valid refresh token
|
|
33
|
+
return res(ctx.status(200), ctx.json({
|
|
34
|
+
access_token: secondAccessToken,
|
|
35
|
+
expires_in: 3600,
|
|
36
|
+
refresh_token: thirdRefreshToken,
|
|
37
|
+
scope: 'client',
|
|
38
|
+
token_type: 'Bearer',
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
// Invalid refresh token
|
|
42
|
+
return res(ctx.status(401), ctx.json({
|
|
43
|
+
error: 'invalid_grant',
|
|
44
|
+
error_description: 'token is invalid or does not exists',
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
if (body.grant_type !== 'password' ||
|
|
48
|
+
body.client_id !== 'ring_official_android' ||
|
|
49
|
+
body.scope !== 'client') {
|
|
50
|
+
return res(ctx.status(400), ctx.json({
|
|
51
|
+
code: 1,
|
|
52
|
+
error: 'Invalid auth request: ' + JSON.stringify(body),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
if (body.username !== email || body.password !== password) {
|
|
56
|
+
// Wrong username or password
|
|
57
|
+
return res(ctx.status(401), ctx.json({
|
|
58
|
+
error: 'access_denied',
|
|
59
|
+
error_description: 'invalid user credentials',
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
if (req.headers.get('2fa-code') &&
|
|
63
|
+
req.headers.get('2fa-code') !== twoFactorAuthCode) {
|
|
64
|
+
// Wrong 2fa code
|
|
65
|
+
return res(ctx.status(400), ctx.json({
|
|
66
|
+
err_msg: 'bad request response from dependency service',
|
|
67
|
+
error: 'Verification Code is invalid or expired',
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
if (req.headers.get('2fa-code') === twoFactorAuthCode) {
|
|
71
|
+
// Successfull login with correct 2fa code
|
|
72
|
+
return res(ctx.json({
|
|
73
|
+
access_token: accessToken,
|
|
74
|
+
expires_in: 3600,
|
|
75
|
+
refresh_token: refreshToken,
|
|
76
|
+
scope: 'client',
|
|
77
|
+
token_type: 'Bearer',
|
|
78
|
+
responseTimestamp: Date.now(),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
// 2fa code not provided, so return the 2fa prompt
|
|
82
|
+
return res(ctx.status(412), ctx.json({
|
|
83
|
+
next_time_in_secs: 60,
|
|
84
|
+
phone,
|
|
85
|
+
tsv_state: 'sms',
|
|
86
|
+
}));
|
|
87
|
+
}), msw_1.rest.post('https://api.ring.com/clients_api/session', async (req, res, ctx) => {
|
|
88
|
+
const authHeader = req.headers.get('Authorization');
|
|
89
|
+
if (authHeader !== `Bearer ${accessToken}` &&
|
|
90
|
+
authHeader !== `Bearer ${secondAccessToken}`) {
|
|
91
|
+
// Invalid access token used
|
|
92
|
+
return res(ctx.status(401));
|
|
93
|
+
}
|
|
94
|
+
const body = await req.json();
|
|
95
|
+
if (body.device.hardware_id !== (await (0, util_1.getHardwareId)()) ||
|
|
96
|
+
body.device.metadata.api_version !== 11 ||
|
|
97
|
+
body.device.metadata.device_model !== 'ring-client-api' ||
|
|
98
|
+
body.device.os !== 'android') {
|
|
99
|
+
return res(ctx.status(400), ctx.body('Bad session request: ' + JSON.stringify(body, null, 2)));
|
|
100
|
+
}
|
|
101
|
+
// Fake a response from the session endpoint, incrementing the sessionCreatedCount
|
|
102
|
+
sessionCreatedCount++;
|
|
103
|
+
return res(ctx.json({
|
|
104
|
+
profile: {
|
|
105
|
+
id: 1234,
|
|
106
|
+
},
|
|
107
|
+
}));
|
|
108
|
+
}));
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
sessionCreatedCount = 0;
|
|
111
|
+
});
|
|
112
|
+
beforeAll(() => {
|
|
113
|
+
// Establish requests interception layer before all tests.
|
|
114
|
+
server.listen();
|
|
115
|
+
});
|
|
116
|
+
afterAll(() => {
|
|
117
|
+
// Clean up after all tests are done, preventing this
|
|
118
|
+
// interception layer from affecting irrelevant tests.
|
|
119
|
+
server.close();
|
|
120
|
+
});
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
client.clearTimeouts();
|
|
123
|
+
(0, util_1.clearTimeouts)();
|
|
124
|
+
});
|
|
125
|
+
describe('getAuth', () => {
|
|
126
|
+
test('It should throw and set the 2fa prompt', async () => {
|
|
127
|
+
client = new rest_client_1.RingRestClient({
|
|
128
|
+
password,
|
|
129
|
+
email,
|
|
130
|
+
});
|
|
131
|
+
await expect(() => client.getAuth()).rejects.toThrow('Your Ring account is configured to use 2-factor authentication (2fa). See https://github.com/dgreif/ring/wiki/Refresh-Tokens for details.');
|
|
132
|
+
expect(client.promptFor2fa).toEqual(`Please enter the code sent to ${phone} via sms`);
|
|
133
|
+
expect(client.using2fa).toEqual(true);
|
|
134
|
+
});
|
|
135
|
+
test('It should accept a 2fa code', async () => {
|
|
136
|
+
client = new rest_client_1.RingRestClient({
|
|
137
|
+
password,
|
|
138
|
+
email,
|
|
139
|
+
});
|
|
140
|
+
// ignore the first reject, it's tested above
|
|
141
|
+
await expect(() => client.getAuth()).rejects.toThrow();
|
|
142
|
+
// call getAuth again with the 2fa code, which should succeed
|
|
143
|
+
const auth = await client.getAuth(twoFactorAuthCode);
|
|
144
|
+
expect(auth).toMatchObject({
|
|
145
|
+
access_token: accessToken,
|
|
146
|
+
refresh_token: refreshToken,
|
|
147
|
+
});
|
|
148
|
+
expect(client.refreshToken).toEqual(refreshToken);
|
|
149
|
+
});
|
|
150
|
+
test('it should handle invalid credentials', async () => {
|
|
151
|
+
client = new rest_client_1.RingRestClient({
|
|
152
|
+
password: 'incorrect password',
|
|
153
|
+
email,
|
|
154
|
+
});
|
|
155
|
+
await expect(() => client.getAuth()).rejects.toThrow('Failed to fetch oauth token from Ring. Verify that your email and password are correct. (error: access_denied)');
|
|
156
|
+
});
|
|
157
|
+
test('it should handle invalid 2fa code', async () => {
|
|
158
|
+
client = new rest_client_1.RingRestClient({
|
|
159
|
+
password,
|
|
160
|
+
email,
|
|
161
|
+
});
|
|
162
|
+
// ignore the first reject, it's tested above
|
|
163
|
+
await expect(() => client.getAuth()).rejects.toThrow();
|
|
164
|
+
// call getAuth again with an invalid 2fa code, which should fail
|
|
165
|
+
await expect(() => client.getAuth('invalid 2fa code')).rejects.toThrow('Verification Code is invalid or expired');
|
|
166
|
+
expect(client.promptFor2fa).toEqual('Invalid 2fa code entered. Please try again.');
|
|
167
|
+
});
|
|
168
|
+
test('it should establish a valid auth token with a valid refresh token', async () => {
|
|
169
|
+
client = new rest_client_1.RingRestClient({
|
|
170
|
+
refreshToken,
|
|
171
|
+
});
|
|
172
|
+
expect(await client.getCurrentAuth()).toMatchObject({
|
|
173
|
+
access_token: accessToken,
|
|
174
|
+
refresh_token: secondRefreshToken,
|
|
175
|
+
});
|
|
176
|
+
expect(client.refreshToken).toEqual(secondRefreshToken);
|
|
177
|
+
});
|
|
178
|
+
test('it should emit an event when a new refresh token is created', async () => {
|
|
179
|
+
client = new rest_client_1.RingRestClient({
|
|
180
|
+
refreshToken,
|
|
181
|
+
});
|
|
182
|
+
const refreshedPromise = (0, rxjs_1.firstValueFrom)(client.onRefreshTokenUpdated), auth = await client.getAuth();
|
|
183
|
+
expect(auth).toMatchObject({
|
|
184
|
+
access_token: accessToken,
|
|
185
|
+
refresh_token: secondRefreshToken,
|
|
186
|
+
});
|
|
187
|
+
expect(await refreshedPromise).toEqual({
|
|
188
|
+
oldRefreshToken: refreshToken,
|
|
189
|
+
newRefreshToken: secondRefreshToken,
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('fetch', () => {
|
|
194
|
+
let invalidateFirstAccessToken = false;
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
invalidateFirstAccessToken = false;
|
|
197
|
+
server.use(msw_1.rest.get('https://api.ring.com/devices/v1/locations', (req, res, ctx) => {
|
|
198
|
+
const authHeader = req.headers.get('Authorization');
|
|
199
|
+
if (invalidateFirstAccessToken &&
|
|
200
|
+
authHeader === `Bearer ${accessToken}`) {
|
|
201
|
+
// Original access token used, but no longer valid
|
|
202
|
+
return res(ctx.status(401));
|
|
203
|
+
}
|
|
204
|
+
if (authHeader !== `Bearer ${accessToken}` &&
|
|
205
|
+
authHeader !== `Bearer ${secondAccessToken}`) {
|
|
206
|
+
// Invalid access token used
|
|
207
|
+
return res(ctx.status(401));
|
|
208
|
+
}
|
|
209
|
+
if (sessionCreatedCount === 0) {
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.error('Request received before session was created');
|
|
212
|
+
// Session not created yet
|
|
213
|
+
return res(ctx.status(404), ctx.text('Session not created yet'));
|
|
214
|
+
}
|
|
215
|
+
return res(ctx.json([]));
|
|
216
|
+
}));
|
|
217
|
+
});
|
|
218
|
+
it('should include the auth token as a header', async () => {
|
|
219
|
+
client = new rest_client_1.RingRestClient({
|
|
220
|
+
refreshToken,
|
|
221
|
+
});
|
|
222
|
+
const response = await client.request({
|
|
223
|
+
url: 'https://api.ring.com/devices/v1/locations',
|
|
224
|
+
});
|
|
225
|
+
expect(response).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
it('should fetch a new auth token if the first is no longer valid', async () => {
|
|
228
|
+
client = new rest_client_1.RingRestClient({
|
|
229
|
+
refreshToken,
|
|
230
|
+
});
|
|
231
|
+
invalidateFirstAccessToken = true;
|
|
232
|
+
const response = await client.request({
|
|
233
|
+
url: 'https://api.ring.com/devices/v1/locations',
|
|
234
|
+
});
|
|
235
|
+
expect(response).toEqual([]);
|
|
236
|
+
});
|
|
237
|
+
});
|
package/lib/util.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ interface Logger {
|
|
|
2
2
|
logInfo: (...message: string[]) => void;
|
|
3
3
|
logError: (message: string) => void;
|
|
4
4
|
}
|
|
5
|
+
export declare function clearTimeouts(): void;
|
|
5
6
|
export declare function delay(milliseconds: number): Promise<unknown>;
|
|
6
7
|
export declare function logDebug(message: any): void;
|
|
7
8
|
export declare function logInfo(...message: any[]): void;
|
package/lib/util.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.randomString = exports.randomInteger = exports.mapAsync = exports.stringify = exports.requestInput = exports.getHardwareId = exports.generateUuid = exports.enableDebug = exports.useLogger = exports.logError = exports.logInfo = exports.logDebug = exports.delay = void 0;
|
|
6
|
+
exports.randomString = exports.randomInteger = exports.mapAsync = exports.stringify = exports.requestInput = exports.getHardwareId = exports.generateUuid = exports.enableDebug = exports.useLogger = exports.logError = exports.logInfo = exports.logDebug = exports.delay = exports.clearTimeouts = void 0;
|
|
7
7
|
const debug_1 = __importDefault(require("debug"));
|
|
8
8
|
const colors_1 = require("colors");
|
|
9
9
|
const readline_1 = require("readline");
|
|
@@ -19,9 +19,20 @@ let logger = {
|
|
|
19
19
|
debugLogger((0, colors_1.red)(message));
|
|
20
20
|
},
|
|
21
21
|
}, debugEnabled = false;
|
|
22
|
+
const timeouts = new Set();
|
|
23
|
+
function clearTimeouts() {
|
|
24
|
+
timeouts.forEach((timeout) => {
|
|
25
|
+
clearTimeout(timeout);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
exports.clearTimeouts = clearTimeouts;
|
|
22
29
|
function delay(milliseconds) {
|
|
23
30
|
return new Promise((resolve) => {
|
|
24
|
-
setTimeout(
|
|
31
|
+
const timeout = setTimeout(() => {
|
|
32
|
+
timeouts.delete(timeout);
|
|
33
|
+
resolve(undefined);
|
|
34
|
+
}, milliseconds);
|
|
35
|
+
timeouts.add(timeout);
|
|
25
36
|
});
|
|
26
37
|
}
|
|
27
38
|
exports.delay = delay;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ring-client-api",
|
|
3
|
-
"version": "11.7.
|
|
3
|
+
"version": "11.7.6",
|
|
4
4
|
"description": "Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting",
|
|
5
5
|
"main": "lib/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"colors": "1.4.0",
|
|
26
26
|
"debug": "^4.3.4",
|
|
27
27
|
"got": "^11.8.5",
|
|
28
|
+
"msw": "^1.2.1",
|
|
28
29
|
"rxjs": "^7.8.0",
|
|
29
30
|
"socket.io-client": "^2.5.0",
|
|
30
31
|
"systeminformation": "^5.17.12",
|