ring-client-api 13.2.1 → 14.0.0-beta.1
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/lib/api.d.ts +10 -8
- package/lib/api.js +57 -64
- package/lib/device-data.js +6 -9
- package/lib/ffmpeg.js +2 -6
- package/lib/index.d.ts +7 -7
- package/lib/index.js +7 -23
- package/lib/location.d.ts +32 -31
- package/lib/location.js +67 -72
- package/lib/refresh-token.js +6 -11
- package/lib/rest-client.d.ts +4 -4
- package/lib/rest-client.js +40 -51
- package/lib/ring-auth-cli.js +2 -4
- package/lib/ring-camera.d.ts +26 -23
- package/lib/ring-camera.js +64 -61
- package/lib/ring-chime.d.ts +7 -7
- package/lib/ring-chime.js +14 -18
- package/lib/ring-device-data-cli.js +2 -4
- package/lib/ring-device.d.ts +7 -7
- package/lib/ring-device.js +16 -20
- package/lib/ring-intercom.d.ts +8 -8
- package/lib/ring-intercom.js +23 -27
- package/lib/ring-types.d.ts +142 -133
- package/lib/ring-types.js +137 -153
- package/lib/streaming/peer-connection.d.ts +5 -3
- package/lib/streaming/peer-connection.js +25 -30
- package/lib/streaming/simple-webrtc-session.d.ts +3 -3
- package/lib/streaming/simple-webrtc-session.js +3 -7
- package/lib/streaming/streaming-messages.d.ts +6 -5
- package/lib/streaming/streaming-messages.js +6 -9
- package/lib/streaming/streaming-session.d.ts +5 -5
- package/lib/streaming/streaming-session.js +34 -38
- package/lib/streaming/webrtc-connection.d.ts +5 -5
- package/lib/streaming/webrtc-connection.js +39 -43
- package/lib/subscribed.d.ts +1 -1
- package/lib/subscribed.js +1 -5
- package/lib/test/rest-client.spec.js +44 -45
- package/lib/test/ring-camera.spec.js +12 -13
- package/lib/util.js +33 -54
- package/package.json +17 -19
|
@@ -1,13 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const util_1 = require("../util");
|
|
9
|
-
const operators_1 = require("rxjs/operators");
|
|
10
|
-
const subscribed_1 = require("../subscribed");
|
|
1
|
+
import { RtpPacket } from 'werift';
|
|
2
|
+
import { FfmpegProcess, reservePorts, RtpSplitter, } from '@homebridge/camera-utils';
|
|
3
|
+
import { firstValueFrom, ReplaySubject, Subject } from 'rxjs';
|
|
4
|
+
import { getFfmpegPath } from "../ffmpeg.js";
|
|
5
|
+
import { logDebug, logError } from "../util.js";
|
|
6
|
+
import { concatMap, map, mergeWith, take } from 'rxjs/operators';
|
|
7
|
+
import { Subscribed } from "../subscribed.js";
|
|
11
8
|
function getCleanSdp(sdp, includeVideo) {
|
|
12
9
|
return sdp
|
|
13
10
|
.split('\nm=')
|
|
@@ -16,16 +13,16 @@ function getCleanSdp(sdp, includeVideo) {
|
|
|
16
13
|
.filter((section) => includeVideo || !section.startsWith('m=video'))
|
|
17
14
|
.join('\n');
|
|
18
15
|
}
|
|
19
|
-
class StreamingSession extends
|
|
16
|
+
export class StreamingSession extends Subscribed {
|
|
17
|
+
onCallEnded = new ReplaySubject(1);
|
|
18
|
+
onUsingOpus = new ReplaySubject(1);
|
|
19
|
+
onVideoRtp = new Subject();
|
|
20
|
+
onAudioRtp = new Subject();
|
|
21
|
+
audioSplitter = new RtpSplitter();
|
|
22
|
+
videoSplitter = new RtpSplitter();
|
|
23
|
+
returnAudioSplitter = new RtpSplitter();
|
|
20
24
|
camera;
|
|
21
25
|
connection;
|
|
22
|
-
onCallEnded = new rxjs_1.ReplaySubject(1);
|
|
23
|
-
onUsingOpus = new rxjs_1.ReplaySubject(1);
|
|
24
|
-
onVideoRtp = new rxjs_1.Subject();
|
|
25
|
-
onAudioRtp = new rxjs_1.Subject();
|
|
26
|
-
audioSplitter = new camera_utils_1.RtpSplitter();
|
|
27
|
-
videoSplitter = new camera_utils_1.RtpSplitter();
|
|
28
|
-
returnAudioSplitter = new camera_utils_1.RtpSplitter();
|
|
29
26
|
constructor(camera, connection) {
|
|
30
27
|
super();
|
|
31
28
|
this.camera = camera;
|
|
@@ -53,11 +50,11 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
53
50
|
this.connection.activateCameraSpeaker();
|
|
54
51
|
}
|
|
55
52
|
async reservePort(bufferPorts = 0) {
|
|
56
|
-
const ports = await
|
|
53
|
+
const ports = await reservePorts({ count: bufferPorts + 1 });
|
|
57
54
|
return ports[0];
|
|
58
55
|
}
|
|
59
56
|
get isUsingOpus() {
|
|
60
|
-
return
|
|
57
|
+
return firstValueFrom(this.onUsingOpus.pipe(mergeWith(this.connection.onError.pipe(map((e) => {
|
|
61
58
|
throw e;
|
|
62
59
|
})))));
|
|
63
60
|
}
|
|
@@ -66,11 +63,11 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
66
63
|
return;
|
|
67
64
|
}
|
|
68
65
|
const videoPort = await this.reservePort(1), audioPort = await this.reservePort(1), transcodeVideoStream = ffmpegOptions.video !== false, ringSdp = await Promise.race([
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
firstValueFrom(this.connection.onCallAnswered),
|
|
67
|
+
firstValueFrom(this.onCallEnded),
|
|
71
68
|
]);
|
|
72
69
|
if (!ringSdp) {
|
|
73
|
-
|
|
70
|
+
logDebug('Call ended before answered');
|
|
74
71
|
return;
|
|
75
72
|
}
|
|
76
73
|
const usingOpus = await this.isUsingOpus, ffmpegInputArguments = [
|
|
@@ -86,21 +83,21 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
86
83
|
'pipe:',
|
|
87
84
|
], inputSdp = getCleanSdp(ringSdp, transcodeVideoStream)
|
|
88
85
|
.replace(/m=audio \d+/, `m=audio ${audioPort}`)
|
|
89
|
-
.replace(/m=video \d+/, `m=video ${videoPort}`), ff = new
|
|
86
|
+
.replace(/m=video \d+/, `m=video ${videoPort}`), ff = new FfmpegProcess({
|
|
90
87
|
ffmpegArgs: ffmpegInputArguments.concat(...(ffmpegOptions.audio || ['-acodec', 'aac']), ...(transcodeVideoStream
|
|
91
88
|
? ffmpegOptions.video || ['-vcodec', 'copy']
|
|
92
89
|
: []), ...(ffmpegOptions.output || [])),
|
|
93
|
-
ffmpegPath:
|
|
90
|
+
ffmpegPath: getFfmpegPath(),
|
|
94
91
|
stdoutCallback: ffmpegOptions.stdoutCallback,
|
|
95
92
|
exitCallback: () => this.callEnded(),
|
|
96
93
|
logLabel: `From Ring (${this.camera.name})`,
|
|
97
94
|
logger: {
|
|
98
|
-
error:
|
|
99
|
-
info:
|
|
95
|
+
error: logError,
|
|
96
|
+
info: logDebug,
|
|
100
97
|
},
|
|
101
98
|
});
|
|
102
99
|
this.addSubscriptions(this.onAudioRtp
|
|
103
|
-
.pipe(
|
|
100
|
+
.pipe(concatMap((rtp) => {
|
|
104
101
|
return this.audioSplitter.send(rtp.serialize(), {
|
|
105
102
|
port: audioPort,
|
|
106
103
|
});
|
|
@@ -108,14 +105,14 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
108
105
|
.subscribe());
|
|
109
106
|
if (transcodeVideoStream) {
|
|
110
107
|
this.addSubscriptions(this.onVideoRtp
|
|
111
|
-
.pipe(
|
|
108
|
+
.pipe(concatMap((rtp) => {
|
|
112
109
|
return this.videoSplitter.send(rtp.serialize(), {
|
|
113
110
|
port: videoPort,
|
|
114
111
|
});
|
|
115
112
|
}))
|
|
116
113
|
.subscribe());
|
|
117
114
|
}
|
|
118
|
-
this.onCallEnded.pipe(
|
|
115
|
+
this.onCallEnded.pipe(take(1)).subscribe(() => ff.stop());
|
|
119
116
|
ff.writeStdin(inputSdp);
|
|
120
117
|
// Request a key frame now that ffmpeg is ready to receive
|
|
121
118
|
this.requestKeyFrame();
|
|
@@ -124,11 +121,11 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
124
121
|
if (this.hasEnded) {
|
|
125
122
|
return;
|
|
126
123
|
}
|
|
127
|
-
const audioOutForwarder = new
|
|
128
|
-
const rtp =
|
|
124
|
+
const audioOutForwarder = new RtpSplitter(({ message }) => {
|
|
125
|
+
const rtp = RtpPacket.deSerialize(message);
|
|
129
126
|
this.connection.sendAudioPacket(rtp);
|
|
130
127
|
return null;
|
|
131
|
-
}), usingOpus = await this.isUsingOpus, ff = new
|
|
128
|
+
}), usingOpus = await this.isUsingOpus, ff = new FfmpegProcess({
|
|
132
129
|
ffmpegArgs: [
|
|
133
130
|
'-hide_banner',
|
|
134
131
|
'-protocol_whitelist',
|
|
@@ -146,15 +143,15 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
146
143
|
'rtp',
|
|
147
144
|
`rtp://127.0.0.1:${await audioOutForwarder.portPromise}`,
|
|
148
145
|
],
|
|
149
|
-
ffmpegPath:
|
|
146
|
+
ffmpegPath: getFfmpegPath(),
|
|
150
147
|
exitCallback: () => this.callEnded(),
|
|
151
148
|
logLabel: `Return Audio (${this.camera.name})`,
|
|
152
149
|
logger: {
|
|
153
|
-
error:
|
|
154
|
-
info:
|
|
150
|
+
error: logError,
|
|
151
|
+
info: logDebug,
|
|
155
152
|
},
|
|
156
153
|
});
|
|
157
|
-
this.onCallEnded.pipe(
|
|
154
|
+
this.onCallEnded.pipe(take(1)).subscribe(() => ff.stop());
|
|
158
155
|
}
|
|
159
156
|
hasEnded = false;
|
|
160
157
|
callEnded() {
|
|
@@ -182,4 +179,3 @@ class StreamingSession extends subscribed_1.Subscribed {
|
|
|
182
179
|
this.connection.requestKeyFrame();
|
|
183
180
|
}
|
|
184
181
|
}
|
|
185
|
-
exports.StreamingSession = StreamingSession;
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { ReplaySubject, Subject } from 'rxjs';
|
|
2
|
-
import { RingCamera } from '../ring-camera';
|
|
3
|
-
import { BasicPeerConnection } from './peer-connection';
|
|
4
|
-
import { Subscribed } from '../subscribed';
|
|
5
|
-
import { RtpPacket } from 'werift';
|
|
2
|
+
import type { RingCamera } from '../ring-camera.ts';
|
|
3
|
+
import { type BasicPeerConnection } from './peer-connection.ts';
|
|
4
|
+
import { Subscribed } from '../subscribed.ts';
|
|
5
|
+
import type { RtpPacket } from 'werift';
|
|
6
6
|
export interface StreamingConnectionOptions {
|
|
7
7
|
createPeerConnection?: () => BasicPeerConnection;
|
|
8
8
|
}
|
|
9
9
|
export declare class WebrtcConnection extends Subscribed {
|
|
10
|
-
private camera;
|
|
11
10
|
private readonly onSessionId;
|
|
12
11
|
private readonly onOfferSent;
|
|
13
12
|
private readonly dialogId;
|
|
@@ -23,6 +22,7 @@ export declare class WebrtcConnection extends Subscribed {
|
|
|
23
22
|
readonly onVideoRtp: Subject<RtpPacket>;
|
|
24
23
|
private readonly pc;
|
|
25
24
|
private readonly ws;
|
|
25
|
+
private camera;
|
|
26
26
|
constructor(ticket: string, camera: RingCamera, options: StreamingConnectionOptions);
|
|
27
27
|
private initiateCall;
|
|
28
28
|
private sessionId;
|
|
@@ -1,31 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
onCallAnswered = new rxjs_1.ReplaySubject(1);
|
|
17
|
-
onCallEnded = new rxjs_1.ReplaySubject(1);
|
|
18
|
-
onError = new rxjs_1.ReplaySubject(1);
|
|
19
|
-
onMessage = new rxjs_1.ReplaySubject();
|
|
1
|
+
import { WebSocket } from 'ws';
|
|
2
|
+
import { firstValueFrom, fromEvent, interval, ReplaySubject, Subject, } from 'rxjs';
|
|
3
|
+
import { concatMap, take } from 'rxjs/operators';
|
|
4
|
+
import { generateUuid, logDebug, logError, logInfo } from "../util.js";
|
|
5
|
+
import { WeriftPeerConnection, } from "./peer-connection.js";
|
|
6
|
+
import { Subscribed } from "../subscribed.js";
|
|
7
|
+
export class WebrtcConnection extends Subscribed {
|
|
8
|
+
onSessionId = new ReplaySubject(1);
|
|
9
|
+
onOfferSent = new ReplaySubject(1);
|
|
10
|
+
dialogId = generateUuid();
|
|
11
|
+
onCameraConnected = new ReplaySubject(1);
|
|
12
|
+
onCallAnswered = new ReplaySubject(1);
|
|
13
|
+
onCallEnded = new ReplaySubject(1);
|
|
14
|
+
onError = new ReplaySubject(1);
|
|
15
|
+
onMessage = new ReplaySubject();
|
|
20
16
|
onWsOpen;
|
|
21
17
|
onAudioRtp;
|
|
22
18
|
onVideoRtp;
|
|
23
19
|
pc;
|
|
24
20
|
ws;
|
|
21
|
+
camera;
|
|
25
22
|
constructor(ticket, camera, options) {
|
|
26
23
|
super();
|
|
27
24
|
this.camera = camera;
|
|
28
|
-
this.ws = new
|
|
25
|
+
this.ws = new WebSocket(`wss://api.prod.signalling.ring.devices.a2z.com:443/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-${generateUuid()}&token=${ticket}`, {
|
|
29
26
|
headers: {
|
|
30
27
|
// This must exist or the socket will close immediately but content does not seem to matter
|
|
31
28
|
'User-Agent': 'android:com.ringapp',
|
|
@@ -35,20 +32,20 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
35
32
|
// we were passed a custom peer connection factory
|
|
36
33
|
this.pc = options.createPeerConnection();
|
|
37
34
|
// passing rtp packets is not supported for custom peer connections
|
|
38
|
-
this.onAudioRtp = new
|
|
39
|
-
this.onVideoRtp = new
|
|
35
|
+
this.onAudioRtp = new Subject();
|
|
36
|
+
this.onVideoRtp = new Subject();
|
|
40
37
|
}
|
|
41
38
|
else {
|
|
42
39
|
// no custom peer connection factory, use the werift and pass along rtp packets
|
|
43
|
-
const pc = new
|
|
40
|
+
const pc = new WeriftPeerConnection();
|
|
44
41
|
this.pc = pc;
|
|
45
42
|
this.onAudioRtp = pc.onAudioRtp;
|
|
46
43
|
this.onVideoRtp = pc.onVideoRtp;
|
|
47
44
|
}
|
|
48
|
-
this.onWsOpen =
|
|
49
|
-
const onMessage =
|
|
45
|
+
this.onWsOpen = fromEvent(this.ws, 'open');
|
|
46
|
+
const onMessage = fromEvent(this.ws, 'message'), onError = fromEvent(this.ws, 'error'), onClose = fromEvent(this.ws, 'close');
|
|
50
47
|
this.addSubscriptions(onMessage
|
|
51
|
-
.pipe(
|
|
48
|
+
.pipe(concatMap((event) => {
|
|
52
49
|
const message = JSON.parse(event.data);
|
|
53
50
|
this.onMessage.next(message);
|
|
54
51
|
return this.handleMessage(message).catch((e) => {
|
|
@@ -61,35 +58,35 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
61
58
|
});
|
|
62
59
|
}))
|
|
63
60
|
.subscribe(), onError.subscribe((e) => {
|
|
64
|
-
|
|
61
|
+
logError(e);
|
|
65
62
|
this.callEnded();
|
|
66
63
|
}), onClose.subscribe(() => {
|
|
67
64
|
this.callEnded();
|
|
68
65
|
}), this.pc.onConnectionState.subscribe((state) => {
|
|
69
66
|
if (state === 'failed') {
|
|
70
|
-
|
|
67
|
+
logError('Stream connection failed');
|
|
71
68
|
this.callEnded();
|
|
72
69
|
}
|
|
73
70
|
if (state === 'closed') {
|
|
74
|
-
|
|
71
|
+
logDebug('Stream connection closed');
|
|
75
72
|
this.callEnded();
|
|
76
73
|
}
|
|
77
74
|
}), this.onError.subscribe((e) => {
|
|
78
|
-
|
|
75
|
+
logError(e);
|
|
79
76
|
this.callEnded();
|
|
80
77
|
}), this.onWsOpen.subscribe(() => {
|
|
81
78
|
const connectionType = camera.isRingEdgeEnabled ? 'Ring Edge' : 'Cloud';
|
|
82
|
-
|
|
79
|
+
logDebug(`WebSocket connected for ${camera.name} (${connectionType})`);
|
|
83
80
|
this.initiateCall().catch((e) => {
|
|
84
|
-
|
|
81
|
+
logError(e);
|
|
85
82
|
this.callEnded();
|
|
86
83
|
});
|
|
87
84
|
}),
|
|
88
85
|
// The ring-edge session needs a ping every 5 seconds to keep the connection alive
|
|
89
|
-
|
|
86
|
+
interval(5000).subscribe(() => {
|
|
90
87
|
this.sendSessionMessage('ping');
|
|
91
88
|
}), this.pc.onIceCandidate.subscribe(async (iceCandidate) => {
|
|
92
|
-
await
|
|
89
|
+
await firstValueFrom(this.onOfferSent);
|
|
93
90
|
this.sendMessage({
|
|
94
91
|
method: 'ice',
|
|
95
92
|
dialog_id: this.dialogId,
|
|
@@ -161,8 +158,8 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
161
158
|
break;
|
|
162
159
|
}
|
|
163
160
|
case 'close':
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
logError('Video stream closed');
|
|
162
|
+
logError(message.body);
|
|
166
163
|
this.callEnded();
|
|
167
164
|
return;
|
|
168
165
|
case 'camera_started':
|
|
@@ -170,8 +167,8 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
170
167
|
// ignore these messages as we don't use them
|
|
171
168
|
return;
|
|
172
169
|
}
|
|
173
|
-
|
|
174
|
-
|
|
170
|
+
logError('UNKNOWN MESSAGE');
|
|
171
|
+
logError(message);
|
|
175
172
|
}
|
|
176
173
|
sendSessionMessage(method, body = {}) {
|
|
177
174
|
const sendSessionMessage = (sessionId) => {
|
|
@@ -193,7 +190,7 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
193
190
|
}
|
|
194
191
|
else {
|
|
195
192
|
// Otherwise wait for the session id to be set and then send the message
|
|
196
|
-
this.addSubscriptions(this.onSessionId.pipe(
|
|
193
|
+
this.addSubscriptions(this.onSessionId.pipe(take(1)).subscribe(sendSessionMessage));
|
|
197
194
|
}
|
|
198
195
|
}
|
|
199
196
|
sendMessage(message) {
|
|
@@ -206,7 +203,7 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
206
203
|
if (this.hasEnded) {
|
|
207
204
|
return;
|
|
208
205
|
}
|
|
209
|
-
if (this.pc instanceof
|
|
206
|
+
if (this.pc instanceof WeriftPeerConnection) {
|
|
210
207
|
this.pc.returnAudioTrack.writeRtp(rtp);
|
|
211
208
|
}
|
|
212
209
|
else {
|
|
@@ -214,7 +211,7 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
214
211
|
}
|
|
215
212
|
}
|
|
216
213
|
activate() {
|
|
217
|
-
|
|
214
|
+
logInfo('Activating Session');
|
|
218
215
|
// the activate_session message is required to keep the stream alive longer than 70 seconds
|
|
219
216
|
this.sendSessionMessage('activate_session');
|
|
220
217
|
this.sendSessionMessage('stream_options', {
|
|
@@ -224,7 +221,7 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
224
221
|
}
|
|
225
222
|
activateCameraSpeaker() {
|
|
226
223
|
// Fire and forget this call so that callers don't get hung up waiting for answer (which might not happen)
|
|
227
|
-
this.addSubscriptions(this.onCameraConnected.pipe(
|
|
224
|
+
this.addSubscriptions(this.onCameraConnected.pipe(take(1)).subscribe(() => {
|
|
228
225
|
this.sendSessionMessage('camera_options', {
|
|
229
226
|
stealth_mode: false,
|
|
230
227
|
});
|
|
@@ -257,4 +254,3 @@ class WebrtcConnection extends subscribed_1.Subscribed {
|
|
|
257
254
|
this.pc.requestKeyFrame?.();
|
|
258
255
|
}
|
|
259
256
|
}
|
|
260
|
-
exports.WebrtcConnection = WebrtcConnection;
|
package/lib/subscribed.d.ts
CHANGED
package/lib/subscribed.js
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.Subscribed = void 0;
|
|
4
|
-
class Subscribed {
|
|
1
|
+
export class Subscribed {
|
|
5
2
|
subscriptions = [];
|
|
6
3
|
addSubscriptions(...subscriptions) {
|
|
7
4
|
this.subscriptions.push(...subscriptions);
|
|
@@ -10,4 +7,3 @@ class Subscribed {
|
|
|
10
7
|
this.subscriptions.forEach((subscription) => subscription.unsubscribe());
|
|
11
8
|
}
|
|
12
9
|
}
|
|
13
|
-
exports.Subscribed = Subscribed;
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const rxjs_1 = require("rxjs");
|
|
1
|
+
import { http, HttpResponse } from 'msw';
|
|
2
|
+
import { setupServer } from 'msw/node';
|
|
3
|
+
import { RingRestClient } from "../rest-client.js";
|
|
4
|
+
import { clearTimeouts, getHardwareId, toBase64 } from "../util.js";
|
|
5
|
+
import { firstValueFrom } from 'rxjs';
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, } from 'vitest';
|
|
8
7
|
let sessionCreatedCount = 0, client;
|
|
9
|
-
const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoFactorAuthCode = '123456', hardwareIdPromise =
|
|
8
|
+
const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoFactorAuthCode = '123456', hardwareIdPromise = 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 = setupServer(http.post('https://oauth.ring.com/oauth/token', async ({ request: req }) => {
|
|
10
9
|
const body = await req.json();
|
|
11
10
|
if (req.headers.get('2fa-support') !== 'true' ||
|
|
12
11
|
req.headers.get('User-Agent') !== 'android:com.ringapp' ||
|
|
13
12
|
req.headers.get('hardware_id') !== (await hardwareIdPromise)) {
|
|
14
|
-
return
|
|
13
|
+
return HttpResponse.json({
|
|
15
14
|
code: 1,
|
|
16
15
|
error: 'Invalid auth headers: ' + JSON.stringify(req.headers, null, 2),
|
|
17
16
|
}, { status: 400 });
|
|
@@ -19,7 +18,7 @@ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoF
|
|
|
19
18
|
if (body.grant_type === 'refresh_token') {
|
|
20
19
|
if (body.refresh_token === refreshToken) {
|
|
21
20
|
// Valid refresh token
|
|
22
|
-
return
|
|
21
|
+
return HttpResponse.json({
|
|
23
22
|
access_token: accessToken,
|
|
24
23
|
expires_in: 3600,
|
|
25
24
|
refresh_token: secondRefreshToken,
|
|
@@ -29,7 +28,7 @@ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoF
|
|
|
29
28
|
}
|
|
30
29
|
if (body.refresh_token === secondRefreshToken) {
|
|
31
30
|
// Valid refresh token
|
|
32
|
-
return
|
|
31
|
+
return HttpResponse.json({
|
|
33
32
|
access_token: secondAccessToken,
|
|
34
33
|
expires_in: 3600,
|
|
35
34
|
refresh_token: thirdRefreshToken,
|
|
@@ -38,7 +37,7 @@ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoF
|
|
|
38
37
|
}, { status: 200 });
|
|
39
38
|
}
|
|
40
39
|
// Invalid refresh token
|
|
41
|
-
return
|
|
40
|
+
return HttpResponse.json({
|
|
42
41
|
error: 'invalid_grant',
|
|
43
42
|
error_description: 'token is invalid or does not exists',
|
|
44
43
|
}, { status: 401 });
|
|
@@ -46,14 +45,14 @@ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoF
|
|
|
46
45
|
if (body.grant_type !== 'password' ||
|
|
47
46
|
body.client_id !== 'ring_official_android' ||
|
|
48
47
|
body.scope !== 'client') {
|
|
49
|
-
return
|
|
48
|
+
return HttpResponse.json({
|
|
50
49
|
code: 1,
|
|
51
50
|
error: 'Invalid auth request: ' + JSON.stringify(body),
|
|
52
51
|
}, { status: 400 });
|
|
53
52
|
}
|
|
54
53
|
if (body.username !== email || body.password !== password) {
|
|
55
54
|
// Wrong username or password
|
|
56
|
-
return
|
|
55
|
+
return HttpResponse.json({
|
|
57
56
|
error: 'access_denied',
|
|
58
57
|
error_description: 'invalid user credentials',
|
|
59
58
|
}, { status: 401 });
|
|
@@ -61,14 +60,14 @@ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoF
|
|
|
61
60
|
if (req.headers.get('2fa-code') &&
|
|
62
61
|
req.headers.get('2fa-code') !== twoFactorAuthCode) {
|
|
63
62
|
// Wrong 2fa code
|
|
64
|
-
return
|
|
63
|
+
return HttpResponse.json({
|
|
65
64
|
err_msg: 'bad request response from dependency service',
|
|
66
65
|
error: 'Verification Code is invalid or expired',
|
|
67
66
|
}, { status: 400 });
|
|
68
67
|
}
|
|
69
68
|
if (req.headers.get('2fa-code') === twoFactorAuthCode) {
|
|
70
69
|
// Successfull login with correct 2fa code
|
|
71
|
-
return
|
|
70
|
+
return HttpResponse.json({
|
|
72
71
|
access_token: accessToken,
|
|
73
72
|
expires_in: 3600,
|
|
74
73
|
refresh_token: refreshToken,
|
|
@@ -78,35 +77,35 @@ const email = 'some@one.com', password = 'abc123!', phone = '+1xxxxxxxx89', twoF
|
|
|
78
77
|
});
|
|
79
78
|
}
|
|
80
79
|
// 2fa code not provided, so return the 2fa prompt
|
|
81
|
-
return
|
|
80
|
+
return HttpResponse.json({
|
|
82
81
|
next_time_in_secs: 60,
|
|
83
82
|
phone,
|
|
84
83
|
tsv_state: 'sms',
|
|
85
84
|
}, { status: 412 });
|
|
86
|
-
}),
|
|
85
|
+
}), http.post('https://api.ring.com/clients_api/session', async ({ request: req }) => {
|
|
87
86
|
const authHeader = req.headers.get('Authorization');
|
|
88
87
|
if (authHeader !== `Bearer ${accessToken}` &&
|
|
89
88
|
authHeader !== `Bearer ${secondAccessToken}`) {
|
|
90
89
|
// Invalid access token used
|
|
91
|
-
return
|
|
90
|
+
return HttpResponse.json({}, { status: 401 });
|
|
92
91
|
}
|
|
93
92
|
const body = await req.json();
|
|
94
|
-
if (body.device.hardware_id !== (await
|
|
93
|
+
if (body.device.hardware_id !== (await getHardwareId()) ||
|
|
95
94
|
body.device.metadata.api_version !== 11 ||
|
|
96
95
|
body.device.metadata.device_model !== 'ring-client-api' ||
|
|
97
96
|
body.device.os !== 'android') {
|
|
98
|
-
return
|
|
97
|
+
return HttpResponse.text('Bad session request: ' + JSON.stringify(body, null, 2), { status: 400 });
|
|
99
98
|
}
|
|
100
99
|
// Fake a response from the session endpoint, incrementing the sessionCreatedCount
|
|
101
100
|
sessionCreatedCount++;
|
|
102
|
-
return
|
|
101
|
+
return HttpResponse.json({
|
|
103
102
|
profile: {
|
|
104
103
|
id: 1234,
|
|
105
104
|
},
|
|
106
105
|
});
|
|
107
106
|
}));
|
|
108
107
|
async function wrapRefreshToken(rt) {
|
|
109
|
-
return
|
|
108
|
+
return toBase64(JSON.stringify({
|
|
110
109
|
rt,
|
|
111
110
|
hid: await hardwareIdPromise,
|
|
112
111
|
}));
|
|
@@ -125,11 +124,11 @@ afterAll(() => {
|
|
|
125
124
|
});
|
|
126
125
|
afterEach(() => {
|
|
127
126
|
client.clearTimeouts();
|
|
128
|
-
|
|
127
|
+
clearTimeouts();
|
|
129
128
|
});
|
|
130
129
|
describe('getAuth', () => {
|
|
131
|
-
|
|
132
|
-
client = new
|
|
130
|
+
it('should throw and set the 2fa prompt', async () => {
|
|
131
|
+
client = new RingRestClient({
|
|
133
132
|
password,
|
|
134
133
|
email,
|
|
135
134
|
});
|
|
@@ -137,8 +136,8 @@ describe('getAuth', () => {
|
|
|
137
136
|
expect(client.promptFor2fa).toEqual(`Please enter the code sent to ${phone} via sms`);
|
|
138
137
|
expect(client.using2fa).toEqual(true);
|
|
139
138
|
});
|
|
140
|
-
|
|
141
|
-
client = new
|
|
139
|
+
it('should accept a 2fa code', async () => {
|
|
140
|
+
client = new RingRestClient({
|
|
142
141
|
password,
|
|
143
142
|
email,
|
|
144
143
|
});
|
|
@@ -152,26 +151,26 @@ describe('getAuth', () => {
|
|
|
152
151
|
});
|
|
153
152
|
expect(client.refreshToken).toEqual(await wrapRefreshToken(refreshToken));
|
|
154
153
|
});
|
|
155
|
-
|
|
156
|
-
client = new
|
|
154
|
+
it('should handle invalid credentials', async () => {
|
|
155
|
+
client = new RingRestClient({
|
|
157
156
|
password: 'incorrect password',
|
|
158
157
|
email,
|
|
159
158
|
});
|
|
160
159
|
await expect(() => client.getAuth()).rejects.toThrow('Failed to fetch oauth token from Ring. Verify that your email and password are correct. (error: access_denied)');
|
|
161
160
|
});
|
|
162
|
-
|
|
163
|
-
client = new
|
|
161
|
+
it('should handle invalid 2fa code', async () => {
|
|
162
|
+
client = new RingRestClient({
|
|
164
163
|
password,
|
|
165
164
|
email,
|
|
166
165
|
});
|
|
167
|
-
// ignore the first reject, it's
|
|
166
|
+
// ignore the first reject, it's it('ove
|
|
168
167
|
await expect(() => client.getAuth()).rejects.toThrow();
|
|
169
168
|
// call getAuth again with an invalid 2fa code, which should fail
|
|
170
169
|
await expect(() => client.getAuth('invalid 2fa code')).rejects.toThrow('Verification Code is invalid or expired');
|
|
171
170
|
expect(client.promptFor2fa).toEqual('Invalid 2fa code entered. Please try again.');
|
|
172
171
|
});
|
|
173
|
-
|
|
174
|
-
client = new
|
|
172
|
+
it('should establish a valid auth token with a valid refresh token', async () => {
|
|
173
|
+
client = new RingRestClient({
|
|
175
174
|
refreshToken,
|
|
176
175
|
});
|
|
177
176
|
expect(await client.getCurrentAuth()).toMatchObject({
|
|
@@ -180,11 +179,11 @@ describe('getAuth', () => {
|
|
|
180
179
|
});
|
|
181
180
|
expect(client.refreshToken).toEqual(await wrapRefreshToken(secondRefreshToken));
|
|
182
181
|
});
|
|
183
|
-
|
|
184
|
-
client = new
|
|
182
|
+
it('should emit an event when a new refresh token is created', async () => {
|
|
183
|
+
client = new RingRestClient({
|
|
185
184
|
refreshToken,
|
|
186
185
|
});
|
|
187
|
-
const refreshedPromise =
|
|
186
|
+
const refreshedPromise = firstValueFrom(client.onRefreshTokenUpdated), auth = await client.getAuth();
|
|
188
187
|
expect(auth).toMatchObject({
|
|
189
188
|
access_token: accessToken,
|
|
190
189
|
refresh_token: await wrapRefreshToken(secondRefreshToken),
|
|
@@ -199,29 +198,29 @@ describe('fetch', () => {
|
|
|
199
198
|
let invalidateFirstAccessToken = false;
|
|
200
199
|
beforeEach(() => {
|
|
201
200
|
invalidateFirstAccessToken = false;
|
|
202
|
-
server.use(
|
|
201
|
+
server.use(http.get('https://api.ring.com/clients_api/some_endpoint', ({ request: req }) => {
|
|
203
202
|
const authHeader = req.headers.get('Authorization');
|
|
204
203
|
if (invalidateFirstAccessToken &&
|
|
205
204
|
authHeader === `Bearer ${accessToken}`) {
|
|
206
205
|
// Original access token used, but no longer valid
|
|
207
|
-
return
|
|
206
|
+
return HttpResponse.json({}, { status: 401 });
|
|
208
207
|
}
|
|
209
208
|
if (authHeader !== `Bearer ${accessToken}` &&
|
|
210
209
|
authHeader !== `Bearer ${secondAccessToken}`) {
|
|
211
210
|
// Invalid access token used
|
|
212
|
-
return
|
|
211
|
+
return HttpResponse.json({}, { status: 401 });
|
|
213
212
|
}
|
|
214
213
|
if (sessionCreatedCount === 0) {
|
|
215
214
|
// Session not created yet
|
|
216
|
-
return
|
|
215
|
+
return HttpResponse.json({
|
|
217
216
|
error: 'Session not found for ' + req.headers.get('hardware_id'),
|
|
218
217
|
}, { status: 404 });
|
|
219
218
|
}
|
|
220
|
-
return
|
|
219
|
+
return HttpResponse.json([]);
|
|
221
220
|
}));
|
|
222
221
|
});
|
|
223
222
|
it('should include the auth token as a header', async () => {
|
|
224
|
-
client = new
|
|
223
|
+
client = new RingRestClient({
|
|
225
224
|
refreshToken,
|
|
226
225
|
});
|
|
227
226
|
const response = await client.request({
|
|
@@ -230,7 +229,7 @@ describe('fetch', () => {
|
|
|
230
229
|
expect(response).toEqual([]);
|
|
231
230
|
});
|
|
232
231
|
it('should fetch a new auth token if the first is no longer valid', async () => {
|
|
233
|
-
client = new
|
|
232
|
+
client = new RingRestClient({
|
|
234
233
|
refreshToken,
|
|
235
234
|
});
|
|
236
235
|
invalidateFirstAccessToken = true;
|