ring-client-api 11.7.5 → 11.7.7

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
@@ -16,6 +16,7 @@ const ffmpeg_1 = require("./ffmpeg");
16
16
  const subscribed_1 = require("./subscribed");
17
17
  const push_receiver_1 = __importDefault(require("@eneris/push-receiver"));
18
18
  const ring_intercom_1 = require("./ring-intercom");
19
+ const json_bigint_1 = __importDefault(require("json-bigint"));
19
20
  class RingApi extends subscribed_1.Subscribed {
20
21
  constructor(options) {
21
22
  super();
@@ -127,14 +128,18 @@ class RingApi extends subscribed_1.Subscribed {
127
128
  senderId: '876313859327', // for Ring android app. 703521446232 for ring-site
128
129
  }), devicesById = {}, sendToDevice = (id, notification) => {
129
130
  devicesById[id]?.processPushNotification(notification);
130
- };
131
+ }, onPushNotificationToken = new rxjs_1.Subject();
131
132
  for (const camera of cameras) {
132
133
  devicesById[camera.id] = camera;
133
134
  }
134
135
  for (const intercom of intercoms) {
135
136
  devicesById[intercom.id] = intercom;
136
137
  }
137
- pushReceiver.onCredentialsChanged(async ({ newCredentials: { fcm: { token }, }, }) => {
138
+ pushReceiver.onCredentialsChanged(({ newCredentials: { fcm: { token }, }, }) => onPushNotificationToken.next(token));
139
+ this.addSubscriptions((0, rxjs_1.combineLatest)([
140
+ onPushNotificationToken,
141
+ this.restClient.onSession, // combined but not used here, just to trigger another request when session is updated
142
+ ]).subscribe(async ([token]) => {
138
143
  try {
139
144
  await this.restClient.request({
140
145
  url: (0, rest_client_1.clientApi)('device'),
@@ -154,11 +159,11 @@ class RingApi extends subscribed_1.Subscribed {
154
159
  catch (e) {
155
160
  (0, util_1.logError)(e);
156
161
  }
157
- });
162
+ }));
158
163
  pushReceiver.onNotification(({ message }) => {
159
164
  const dataJson = message.data?.gcmData;
160
165
  try {
161
- const notification = JSON.parse(dataJson);
166
+ const notification = (0, json_bigint_1.default)({ storeAsString: true }).parse(dataJson);
162
167
  if ('ding' in notification) {
163
168
  sendToDevice(notification.ding.doorbot_id, notification);
164
169
  }
@@ -236,6 +241,7 @@ class RingApi extends subscribed_1.Subscribed {
236
241
  (0, util_1.logError)(e);
237
242
  });
238
243
  this.restClient.clearTimeouts();
244
+ (0, util_1.clearTimeouts)();
239
245
  }
240
246
  }
241
247
  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;
@@ -99,18 +99,18 @@ class RingCamera extends subscribed_1.Subscribed {
99
99
  this.onInHomeDoorbellStatus = this.onData.pipe((0, operators_1.map)(({ settings: { chime_settings } }) => {
100
100
  return Boolean(chime_settings?.enable);
101
101
  }), (0, operators_1.distinctUntilChanged)());
102
- if (!initialData.subscribed) {
102
+ this.addSubscriptions(this.restClient.onSession.subscribe(() => {
103
103
  this.subscribeToDingEvents().catch((e) => {
104
104
  (0, util_1.logError)('Failed to subscribe ' + initialData.description + ' to ding events');
105
105
  (0, util_1.logError)(e);
106
106
  });
107
- }
108
- if (!initialData.subscribed_motions) {
109
107
  this.subscribeToMotionEvents().catch((e) => {
110
- (0, util_1.logError)('Failed to subscribe ' + initialData.description + ' to motion events');
108
+ (0, util_1.logError)('Failed to subscribe ' +
109
+ initialData.description +
110
+ ' to motion events');
111
111
  (0, util_1.logError)(e);
112
112
  });
113
- }
113
+ }));
114
114
  }
115
115
  updateData(update) {
116
116
  this.onData.next(update);
@@ -879,7 +879,7 @@ export interface PushNotificationDing {
879
879
  device_kind: RingCameraKind;
880
880
  detection_type: NotificationDetectionType;
881
881
  human_detected?: boolean;
882
- id: number;
882
+ id: string;
883
883
  pod_id: number;
884
884
  request_id: string;
885
885
  image_uuid: string;
@@ -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.7",
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,8 @@
25
25
  "colors": "1.4.0",
26
26
  "debug": "^4.3.4",
27
27
  "got": "^11.8.5",
28
+ "json-bigint": "^1.0.0",
29
+ "msw": "^1.2.1",
28
30
  "rxjs": "^7.8.0",
29
31
  "socket.io-client": "^2.5.0",
30
32
  "systeminformation": "^5.17.12",
@@ -36,6 +38,7 @@
36
38
  "devDependencies": {
37
39
  "@types/debug": "4.1.7",
38
40
  "@types/jest": "29.5.0",
41
+ "@types/json-bigint": "^1.0.1",
39
42
  "@types/node": "18.15.9",
40
43
  "@types/uuid": "9.0.1",
41
44
  "@types/ws": "^8.5.4",