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 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(async ({ newCredentials: { fcm: { token }, }, }) => {
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;
@@ -36,6 +36,7 @@ export declare class RingRestClient {
36
36
  oldRefreshToken?: string | undefined;
37
37
  newRefreshToken: string;
38
38
  }>;
39
+ onSession: ReplaySubject<SessionResponse>;
39
40
  readonly baseSessionMetadata: {
40
41
  api_version: number;
41
42
  device_model: string;
@@ -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
- return await this.fetchNewSession(authToken);
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(resolve, milliseconds);
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.5",
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",