ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
443 lines (442 loc) • 17.6 kB
JavaScript
import { delay, fromBase64, getHardwareId, logDebug, logError, logInfo, stringify, toBase64, } from "./util.js";
import { ReplaySubject } from 'rxjs';
import assert from 'assert';
import { Agent } from 'undici';
const fetchAgent = new Agent({
connections: 6,
pipelining: 1,
keepAliveTimeout: 115000,
}), defaultRequestOptions = {
responseType: 'json',
method: 'GET',
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/', commandsApiBaseUrl = 'https://api.ring.com/commands/v1/', appApiBaseUrl = 'https://prd-api-us.prd.rings.solutions/api/v1/', apiVersion = 11;
export function clientApi(path) {
return clientApiBaseUrl + path;
}
export function deviceApi(path) {
return deviceApiBaseUrl + path;
}
export function commandsApi(path) {
return commandsApiBaseUrl + path;
}
export function appApi(path) {
return appApiBaseUrl + path;
}
async function responseToError(response) {
const error = new Error();
error.response = {
headers: response.headers,
status: response.status,
body: null,
};
try {
const bodyText = await response.text();
try {
error.response.body = JSON.parse(bodyText);
}
catch {
error.response.body = bodyText;
}
}
catch {
// ignore
}
return error;
}
async function requestWithRetry(requestOptions, retryCount = 0) {
if (typeof fetch !== 'function') {
throw new Error(`Your current NodeJS version (${process.version}) is too old to support this plugin. Please upgrade to the latest LTS version of NodeJS.`);
}
try {
if (requestOptions.json || requestOptions.responseType === 'json') {
requestOptions.headers = {
...requestOptions.headers,
'Content-Type': 'application/json',
Accept: 'application/json',
};
if (requestOptions.json) {
requestOptions.body = JSON.stringify(requestOptions.json);
}
delete requestOptions.json;
}
const options = {
...defaultRequestOptions,
...requestOptions,
dispatcher: fetchAgent,
};
// If a timeout is provided, create an AbortSignal for it
if (options.timeout && !options.signal) {
options.signal = AbortSignal.timeout(options.timeout);
}
// make the fetch request
const response = await fetch(options.url, options), headers = response.headers;
if (!response.ok) {
const error = await responseToError(response);
throw error;
}
let data;
if (options.responseType === 'buffer') {
const arrayBuffer = await response.arrayBuffer();
data = Buffer.from(arrayBuffer);
}
else {
const text = await response.text();
try {
data = JSON.parse(text);
}
catch {
data = text;
}
}
if (data !== null && typeof data === 'object') {
const date = headers.get('date');
if (date) {
data.responseTimestamp = new Date(date).getTime();
}
const xTime = headers.get('x-time-millis');
if (xTime) {
data.timeMillis = Number(xTime);
}
}
return data;
}
catch (e) {
if (!e.response && !requestOptions.allowNoResponse) {
if (retryCount > 0) {
let detailedError = `Error: ${e.message}`;
detailedError += e.cause?.message ? `, Cause: ${e.cause.message}` : '';
detailedError += e.cause?.code ? `, Code: ${e.cause.code}` : '';
logError(`Retry #${retryCount} failed to reach Ring server at ${requestOptions.url}. ${detailedError}. Trying again in 5 seconds...`);
if (e.message.includes('NGHTTP2_ENHANCE_YOUR_CALM')) {
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`);
}
logDebug(e);
}
await delay(5000);
return requestWithRetry(requestOptions, retryCount + 1);
}
throw e;
}
}
function parseAuthConfig(rawRefreshToken) {
if (!rawRefreshToken) {
return;
}
try {
const config = JSON.parse(fromBase64(rawRefreshToken));
assert(config);
assert(config.rt);
return config;
}
catch {
return {
rt: rawRefreshToken,
};
}
}
export class RingRestClient {
refreshToken;
authConfig;
hardwareIdPromise;
_authPromise;
timeouts = [];
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;
}
sessionPromise = undefined;
using2fa = false;
promptFor2fa;
onRefreshTokenUpdated = new ReplaySubject(1);
onSession = new ReplaySubject(1);
baseSessionMetadata;
authOptions;
constructor(authOptions) {
this.authOptions = authOptions;
this.refreshToken =
'refreshToken' in authOptions ? authOptions.refreshToken : undefined;
this.authConfig = parseAuthConfig(this.refreshToken);
this.hardwareIdPromise =
this.authConfig?.hid || getHardwareId(authOptions.systemId);
this.baseSessionMetadata = {
api_version: apiVersion,
device_model: authOptions.controlCenterDisplayName ?? 'ring-client-api',
};
}
getGrantData(twoFactorAuthCode) {
if (this.authConfig?.rt && !twoFactorAuthCode) {
return {
grant_type: 'refresh_token',
refresh_token: this.authConfig.rt,
};
}
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');
}
async getAuth(twoFactorAuthCode) {
const grantData = this.getGrantData(twoFactorAuthCode);
try {
const hardwareId = await this.hardwareIdPromise, response = await requestWithRetry({
url: 'https://oauth.ring.com/oauth/token',
json: {
client_id: 'ring_official_android',
scope: 'client',
...grantData,
},
method: 'POST',
headers: {
'2fa-support': 'true',
'2fa-code': twoFactorAuthCode || '',
hardware_id: hardwareId,
'User-Agent': 'android:com.ringapp',
},
}), oldRefreshToken = this.refreshToken;
// Store the new refresh token and auth config
this.authConfig = {
...this.authConfig,
rt: response.refresh_token,
hid: hardwareId,
};
this.refreshToken = toBase64(JSON.stringify(this.authConfig));
// Emit an event with the new token
this.onRefreshTokenUpdated.next({
oldRefreshToken,
newRefreshToken: this.refreshToken,
});
return {
...response,
// Override the refresh token in the response so that consumers of this data get the wrapped version
refresh_token: this.refreshToken,
};
}
catch (requestError) {
if (grantData.refresh_token) {
// failed request with refresh token
this.refreshToken = undefined;
this.authConfig = undefined;
logError(requestError);
return this.getAuth();
}
const response = requestError.response || {}, responseData = response.body || {}, responseError = 'error' in responseData && typeof responseData.error === 'string'
? responseData.error
: '';
if (response.status === 412 || // need 2fa code
(response.status === 400 &&
responseError.startsWith('Verification Code')) // invalid 2fa code entered
) {
this.using2fa = true;
if (response.status === 400) {
this.promptFor2fa = 'Invalid 2fa code entered. Please try again.';
throw new Error(responseError);
}
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})`;
logError(requestError.response || requestError);
logError(errorMessage);
throw new Error(errorMessage);
}
}
async fetchNewSession(authToken) {
return requestWithRetry({
url: clientApi('session'),
json: {
device: {
hardware_id: await this.hardwareIdPromise,
metadata: this.baseSessionMetadata,
os: 'android', // can use android, ios, ring-site, windows for sure
},
},
method: 'POST',
headers: {
authorization: `Bearer ${authToken.access_token}`,
},
});
}
getSession() {
return this.authPromise.then(async (authToken) => {
try {
const session = await this.fetchNewSession(authToken);
this.onSession.next(session);
return session;
}
catch (e) {
const response = e.response || {};
if (response.status === 401) {
await this.refreshAuth();
return this.getSession();
}
if (response.status === 429) {
const retryAfter = e.response.headers.get('retry-after'), waitSeconds = isNaN(retryAfter)
? 200
: Number.parseInt(retryAfter, 10);
logError(`Session response rate limited. Waiting to retry after ${waitSeconds} seconds`);
await delay((waitSeconds + 1) * 1000);
logInfo('Retrying session request');
return this.getSession();
}
throw e;
}
});
}
async refreshAuth() {
this.clearPreviousAuth();
await this.authPromise;
}
refreshSession() {
this.sessionPromise = this.getSession();
this.sessionPromise
.finally(() => {
// Refresh the session every 12 hours
// This is needed to keep the session alive for users outside the US, due to Data Residency laws
// We believe Ring is clearing the session info after ~24 hours, which breaks Push Notifications
const timeout = setTimeout(() => {
this.refreshSession();
}, 12 * 60 * 60 * 1000); // 12 hours
this.timeouts.push(timeout);
})
.catch((e) => logError(e));
}
async request(options) {
const hardwareId = await this.hardwareIdPromise, url = options.url, initialSessionPromise = this.sessionPromise;
try {
await initialSessionPromise;
const authTokenResponse = await this.authPromise;
return await requestWithRetry({
...options,
headers: {
...options.headers,
authorization: `Bearer ${authTokenResponse.access_token}`,
hardware_id: hardwareId,
'User-Agent': 'android:com.ringapp',
},
});
}
catch (e) {
const response = e.response || {};
if (response.status === 401) {
await this.refreshAuth();
return this.request(options);
}
if (response.status === 504) {
// Gateway Timeout. These should be recoverable, but wait a few seconds just to be on the safe side
await delay(5000);
return this.request(options);
}
if (response.status === 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) {
logError(`http request failed. ${url} returned errors: (${errorText}). Trying again in 20 seconds`);
await delay(20000);
return this.request(options);
}
logError(`http request failed. ${url} returned unknown errors: (${stringify(errors)}).`);
}
if (response.status === 404 && url.startsWith(clientApiBaseUrl)) {
logError('404 from endpoint ' + url);
if (response.body?.error?.includes(hardwareId)) {
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: ' + stringify(response.body));
}
if (response.status) {
logError(`Request to ${url} failed with status ${response.status}. Response body: ${stringify(response.body)}`);
}
else if (!options.allowNoResponse) {
logError(`Request to ${url} failed:`);
logError(e);
}
throw e;
}
}
getCurrentAuth() {
return this.authPromise;
}
clearTimeouts() {
this.timeouts.forEach(clearTimeout);
}
get _internalOnly_pushNotificationCredentials() {
return this.authConfig?.pnc;
}
set _internalOnly_pushNotificationCredentials(credentials) {
if (!this.refreshToken || !this.authConfig) {
throw new Error('Cannot set push notification credentials without a refresh token');
}
const oldRefreshToken = this.refreshToken;
this.authConfig = {
...this.authConfig,
pnc: credentials,
};
// SOMEDAY: refactor the conversion from auth config to refresh token - DRY from above
const newRefreshToken = toBase64(JSON.stringify(this.authConfig));
if (newRefreshToken === oldRefreshToken) {
// No change, so we don't need to emit an updated refresh token
return;
}
// Save and emit the updated refresh token
this.refreshToken = newRefreshToken;
this.onRefreshTokenUpdated.next({
oldRefreshToken,
newRefreshToken,
});
}
}