@koush/ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
305 lines (304 loc) • 14.3 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.RingRestClient = exports.appApi = exports.deviceApi = exports.clientApi = void 0;
const got_1 = __importDefault(require("got"));
const util_1 = require("./util");
const rxjs_1 = require("rxjs");
const defaultRequestOptions = {
responseType: 'json',
method: 'GET',
retry: 0,
timeout: 20000,
}, ringErrorCodes = {
7050: 'NO_ASSET',
7019: 'ASSET_OFFLINE',
7061: 'ASSET_CELL_BACKUP',
7062: 'UPDATING',
7063: 'MAINTENANCE',
}, clientApiBaseUrl = 'https://api.ring.com/clients_api/', deviceApiBaseUrl = 'https://api.ring.com/devices/v1/', appApiBaseUrl = 'https://app.ring.com/api/v1/', apiVersion = 11;
function clientApi(path) {
return clientApiBaseUrl + path;
}
exports.clientApi = clientApi;
function deviceApi(path) {
return deviceApiBaseUrl + path;
}
exports.deviceApi = deviceApi;
function appApi(path) {
return appApiBaseUrl + path;
}
exports.appApi = appApi;
function requestWithRetry(requestOptions) {
return __awaiter(this, void 0, void 0, function* () {
try {
const options = Object.assign(Object.assign({}, defaultRequestOptions), requestOptions), { headers, body } = (yield (0, got_1.default)(options)), data = body;
if (data !== null && typeof data === 'object') {
if (headers.date) {
data.responseTimestamp = new Date(headers.date).getTime();
}
if (headers['x-time-millis']) {
data.timeMillis = Number(headers['x-time-millis']);
}
}
return data;
}
catch (e) {
if (!e.response) {
(0, util_1.logError)(`Failed to reach Ring server at ${requestOptions.url}. ${e.message}. Trying again in 5 seconds...`);
if (e.message.includes('NGHTTP2_ENHANCE_YOUR_CALM')) {
(0, util_1.logError)(`There is a known issue with your current NodeJS version (${process.version}). Please see https://github.com/dgreif/ring/wiki/NGHTTP2_ENHANCE_YOUR_CALM-Error for details`);
}
(0, util_1.logDebug)(e);
yield (0, util_1.delay)(5000);
return requestWithRetry(requestOptions);
}
throw e;
}
});
}
class RingRestClient {
constructor(authOptions) {
this.authOptions = authOptions;
this.timeouts = [];
this.sessionPromise = undefined;
this.using2fa = false;
this.onRefreshTokenUpdated = new rxjs_1.ReplaySubject(1);
this.refreshToken =
'refreshToken' in this.authOptions
? this.authOptions.refreshToken
: undefined;
this.hardwareIdPromise = (0, util_1.getHardwareId)(this.authOptions.systemId);
}
clearPreviousAuth() {
this._authPromise = undefined;
}
get authPromise() {
if (!this._authPromise) {
const authPromise = this.getAuth();
this._authPromise = authPromise;
authPromise
.then(({ expires_in }) => {
// clear the existing auth promise 1 minute before it expires
const timeout = setTimeout(() => {
if (this._authPromise === authPromise) {
this.clearPreviousAuth();
}
}, ((expires_in || 3600) - 60) * 1000);
this.timeouts.push(timeout);
})
.catch(() => {
// ignore these errors here, they should be handled by the function making a rest request
});
}
return this._authPromise;
}
getGrantData(twoFactorAuthCode) {
if (this.refreshToken && !twoFactorAuthCode) {
return {
grant_type: 'refresh_token',
refresh_token: this.refreshToken,
};
}
const { authOptions } = this;
if ('email' in authOptions) {
return {
grant_type: 'password',
password: authOptions.password,
username: authOptions.email,
};
}
throw new Error('Refresh token is not valid. Unable to authenticate with Ring servers. See https://github.com/dgreif/ring/wiki/Refresh-Tokens');
}
getAuth(twoFactorAuthCode) {
return __awaiter(this, void 0, void 0, function* () {
const grantData = this.getGrantData(twoFactorAuthCode);
try {
const response = yield requestWithRetry({
url: 'https://oauth.ring.com/oauth/token',
json: Object.assign({ client_id: 'ring_official_android', scope: 'client' }, grantData),
method: 'POST',
headers: {
'2fa-support': 'true',
'2fa-code': twoFactorAuthCode || '',
hardware_id: yield this.hardwareIdPromise,
},
});
this.onRefreshTokenUpdated.next({
oldRefreshToken: this.refreshToken,
newRefreshToken: response.refresh_token,
});
this.refreshToken = response.refresh_token;
return response;
}
catch (requestError) {
if (grantData.refresh_token) {
// failed request with refresh token
this.refreshToken = undefined;
(0, util_1.logError)(requestError);
return this.getAuth();
}
const response = requestError.response || {}, responseData = response.body || {}, responseError = 'error' in responseData && typeof responseData.error === 'string'
? responseData.error
: '';
if (response.statusCode === 412 || // need 2fa code
(response.statusCode === 400 &&
responseError.startsWith('Verification Code')) // invalid 2fa code entered
) {
this.using2fa = true;
if ('tsv_state' in responseData) {
const { tsv_state, phone } = responseData, prompt = tsv_state === 'totp'
? 'from your authenticator app'
: `sent to ${phone} via ${tsv_state}`;
this.promptFor2fa = `Please enter the code ${prompt}`;
}
else {
this.promptFor2fa = 'Please enter the code sent to your text/email';
}
throw new Error('Your Ring account is configured to use 2-factor authentication (2fa). See https://github.com/dgreif/ring/wiki/Refresh-Tokens for details.');
}
const authTypeMessage = 'refreshToken' in this.authOptions
? 'refresh token is'
: 'email and password are', errorMessage = 'Failed to fetch oauth token from Ring. ' +
('error_description' in responseData &&
responseData.error_description ===
'too many requests from dependency service'
? 'You have requested too many 2fa codes. Ring limits 2fa to 10 codes within 10 minutes. Please try again in 10 minutes.'
: `Verify that your ${authTypeMessage} correct.`) +
` (error: ${responseError})`;
(0, util_1.logError)(requestError.response || requestError);
(0, util_1.logError)(errorMessage);
throw new Error(errorMessage);
}
});
}
fetchNewSession(authToken) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
return requestWithRetry({
url: clientApi('session'),
json: {
device: {
hardware_id: yield this.hardwareIdPromise,
metadata: {
api_version: apiVersion,
device_model: (_a = this.authOptions.controlCenterDisplayName) !== null && _a !== void 0 ? _a : 'ring-client-api',
},
os: 'android', // can use android, ios, ring-site, windows for sure
},
},
method: 'POST',
headers: {
authorization: `Bearer ${authToken.access_token}`,
},
});
});
}
getSession() {
return this.authPromise.then((authToken) => __awaiter(this, void 0, void 0, function* () {
try {
return yield this.fetchNewSession(authToken);
}
catch (e) {
const response = e.response || {};
if (response.statusCode === 401) {
yield this.refreshAuth();
return this.getSession();
}
if (response.statusCode === 429) {
const retryAfter = e.response.headers['retry-after'], waitSeconds = isNaN(retryAfter)
? 200
: Number.parseInt(retryAfter, 10);
(0, util_1.logError)(`Session response rate limited. Waiting to retry after ${waitSeconds} seconds`);
yield (0, util_1.delay)((waitSeconds + 1) * 1000);
(0, util_1.logInfo)('Retrying session request');
return this.getSession();
}
throw e;
}
}));
}
refreshAuth() {
return __awaiter(this, void 0, void 0, function* () {
this.clearPreviousAuth();
yield this.authPromise;
});
}
refreshSession() {
this.sessionPromise = this.getSession();
}
request(options) {
var _a, _b;
return __awaiter(this, void 0, void 0, function* () {
const hardwareId = yield this.hardwareIdPromise, url = options.url, initialSessionPromise = this.sessionPromise;
try {
yield initialSessionPromise;
const authTokenResponse = yield this.authPromise;
return yield requestWithRetry(Object.assign(Object.assign({}, options), { headers: Object.assign(Object.assign({}, options.headers), { authorization: `Bearer ${authTokenResponse.access_token}`, hardware_id: hardwareId, 'User-Agent': 'android:com.ringapp' }) }));
}
catch (e) {
const response = e.response || {};
if (response.statusCode === 401) {
yield this.refreshAuth();
return this.request(options);
}
if (response.statusCode === 504) {
// Gateway Timeout. These should be recoverable, but wait a few seconds just to be on the safe side
yield (0, util_1.delay)(5000);
return this.request(options);
}
if (response.statusCode === 404 &&
response.body &&
Array.isArray(response.body.errors)) {
const errors = response.body.errors, errorText = errors
.map((code) => ringErrorCodes[code])
.filter((x) => x)
.join(', ');
if (errorText) {
(0, util_1.logError)(`http request failed. ${url} returned errors: (${errorText}). Trying again in 20 seconds`);
yield (0, util_1.delay)(20000);
return this.request(options);
}
(0, util_1.logError)(`http request failed. ${url} returned unknown errors: (${(0, util_1.stringify)(errors)}).`);
}
if (response.statusCode === 404 && url.startsWith(clientApiBaseUrl)) {
(0, util_1.logError)('404 from endpoint ' + url);
if ((_b = (_a = response.body) === null || _a === void 0 ? void 0 : _a.error) === null || _b === void 0 ? void 0 : _b.includes(hardwareId)) {
(0, util_1.logError)('Session hardware_id not found. Creating a new session and trying again.');
if (this.sessionPromise === initialSessionPromise) {
this.refreshSession();
}
return this.request(options);
}
throw new Error('Not found with response: ' + (0, util_1.stringify)(response.body));
}
if (response.statusCode) {
(0, util_1.logError)(`Request to ${url} failed with status ${response.statusCode}. Response body: ${(0, util_1.stringify)(response.body)}`);
}
else {
(0, util_1.logError)(`Request to ${url} failed:`);
(0, util_1.logError)(e);
}
throw e;
}
});
}
getCurrentAuth() {
return this.authPromise;
}
clearTimeouts() {
this.timeouts.forEach(clearTimeout);
}
}
exports.RingRestClient = RingRestClient;