UNPKG

node-hue-api

Version:
286 lines (284 loc) 12.1 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RemoteApi = void 0; const httpClient = __importStar(require("./HttpClientFetch")); const ApiError_1 = require("../../ApiError"); const OAuthTokens_1 = require("./OAuthTokens"); const util_1 = require("../../util"); const crypto_1 = require("crypto"); // This class is a bit different to the other endpoints currently as they operate in a digest challenge for the most // part and also operate off a different base url compared with the local/remote endpoints that make up the rest of the // bridge API commands. class RemoteApi { constructor(clientId, clientSecret) { this._config = { clientId: clientId, clientSecret: clientSecret, baseUrl: new URL('https://api.meethue.com'), }; this._tokens = new OAuthTokens_1.OAuthTokens(); } get clientId() { return this._config.clientId; } get clientSecret() { return this._config.clientSecret; } get baseUrl() { return this._config.baseUrl.href; } get accessToken() { return this._tokens.accessTokenValue; } get accessTokenExpiry() { return this._tokens.accessTokenExpiresAt; } get refreshToken() { return this._tokens.refreshTokenValue; } get refreshTokenExpiry() { return this._tokens.refreshTokenExpiresAt; } setAccessToken(token, expiry) { this._tokens._setAccessToken(token, expiry); return this; } setRefreshToken(token, expiry) { this._tokens._setRefreshToken(token, expiry); return this; } /** * Builds the digest response to pass to the remote API for the provided request details. */ getDigestResponse(realm, nonce, method, path) { const clientId = this.clientId, clientSecret = this.clientSecret, hashOne = (0, crypto_1.createHash)('md5').update(`${clientId}:${realm}:${clientSecret}`).digest('hex'), hashTwo = (0, crypto_1.createHash)('md5').update(`${method.toUpperCase()}:${path}`).digest('hex'), hash = (0, crypto_1.createHash)('md5').update(`${hashOne}:${nonce}:${hashTwo}`).digest('hex'); if (!clientId) { throw new ApiError_1.ApiError('clientId has not been provided, unable to build a digest response'); } if (!clientSecret) { throw new ApiError_1.ApiError('clientSecret has not been provided, unable to build a digest response'); } return hash; } /** * Constructs the digest authorization header value from the provided details. * @returns {string} The value to be used for the "Authorization" Header. */ getAuthorizationHeaderDigest(realm, nonce, method, path) { const clientId = this.clientId, response = this.getDigestResponse(realm, nonce, method, path); return `Digest username="${clientId}", realm="${realm}", nonce="${nonce}", uri="${path}", response="${response}"`; } /** * Constructs the basic authorization header value from the provided details. * * This is really poor for security, it is only included to complete the implementation of the APIs, you are strongly * advised to use the digest authorization instead. * @returns {string} The value to be used for the "Authorization" Header. */ getAuthorizationHeaderBasic() { const clientId = this.clientId, clientSecret = this.clientSecret, encoded = Buffer.from(`${clientId}:${clientSecret}`, 'ascii').toString('base64'); return `Basic ${encoded}`; } /** * Exchanges the code for OAuth tokens. * @param code The authorization code that is provided as part of the OAuth flow. * @returns The OAuth Tokens obtained from the remote portal. */ getToken(code) { const self = this, config = { baseURL: self.baseUrl, headers: { 'Accept': 'application/json' }, responseType: 'json', }, requestConfig = { url: '/v2/oauth2/token', method: 'POST', params: { code: code, grant_type: 'authorization_code' }, validateStatus: (status) => { return status === 401; } }, start = Date.now(); const http = httpClient.create(config); return http.request(requestConfig) .then(res => { return self._respondWithDigest(http, res, requestConfig); }) .then(res => { if (res.status === 200) { return self._processTokens(start, res.data); } else { throw new ApiError_1.ApiError(`Unexpected status code from getting token: ${res.status}`); } }); } /** * Refreshes the existing tokens by exchanging the current refresh token for new access and refresh tokens. * * After calling this the old tokens will no longer be valid. The new tokens obtained will be injected back into the * API for future calls. * * You should ensure you save the new tokens in place of the previous ones that you used to establish the original * remote connection. * * @param refreshToken The refresh token to exchange for new tokens. * @returns Promise<Tokens> The new refreshed tokens. */ refreshTokens(refreshToken) { const self = this, config = { baseURL: self.baseUrl, headers: { 'Accept': 'application/json' }, responseType: 'json', }, requestConfig = { url: '/v2/oauth2/token', method: 'POST', params: { grant_type: 'refresh_token', refresh_token: refreshToken }, validateStatus: (status) => { return status === 401; } }, start = Date.now(); const http = httpClient.create(config); return http.request(requestConfig) .then(res => { return self._respondWithDigest(http, res, requestConfig); }) .then(res => { if (res.status === 200) { return self._processTokens(start, res.data); } else { throw new ApiError_1.ApiError(`Unexpected status code from refreshing tokens: ${res.status}`); } }); } /** * Creates a new remote user * @param remoteBridgeId The id of the hue bridge in the remote portal, usually 0. * @param deviceType The user device type identifier (this is shown to the end users on the remote access portal). If not specified will default to 'node-hue-api-remote'. * @returns The new remote username. */ createRemoteUsername(remoteBridgeId, deviceType) { const self = this, accessToken = self.accessToken; if (Number.isNaN(Number.parseInt(remoteBridgeId))) { // default to bridge id 0 (as this will be the case for most users remoteBridgeId = 0; } if (!accessToken) { throw new ApiError_1.ApiError('No current valid access token, you need to fetch an access token before continuing.'); } const remoteApi = httpClient.create({ baseURL: self.baseUrl, headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); // return remoteApi.put(`/bridge/${remoteBridgeId}/config`, {'linkbutton': true}) return remoteApi.request({ url: `/bridge/${remoteBridgeId}/config`, method: 'PUT', data: { 'linkbutton': true }, json: true, }).then(res => { if (!(0, util_1.wasSuccessful)(res.data)) { throw new ApiError_1.ApiError(`Issue with activating remote link button, attempt was not successful: ${JSON.stringify(res.data)}`); } // return remoteApi.post('/bridge', {devicetype: deviceType || 'node-hue-api-remote'}) return remoteApi.request({ url: '/bridge', data: { devicetype: deviceType || 'node-hue-api-remote' }, method: 'POST', json: true, }).then(res => { if ((0, util_1.wasSuccessful)(res.data)) { return res.data[0].success.username; } else { throw new ApiError_1.ApiError(`Failed to create a remote whitelist user: ${JSON.stringify(res.data)}`); } }); }); } _respondWithDigest(http, res, requestConfig) { // We need this information to build the digest Authorization header and get the nonce that we can use for the // request that will be properly validated and issue us the authorization tokens. const status = res.status; if (status !== 401) { throw new ApiError_1.ApiError(`Did not get the expected 401 response from the remote API that contains the www-authenticate details needed to proceed, got status ${status}`); } const wwwAuthenticate = getAuthenticationDetailsFromHeader(res.headers), digestHeader = this.getAuthorizationHeaderDigest(wwwAuthenticate.realm, wwwAuthenticate.nonce, requestConfig.method, requestConfig.url); requestConfig.headers = { 'Authorization': digestHeader }; requestConfig.validateStatus = undefined; return http.request(requestConfig); } _processTokens(start, data) { this.setAccessToken(data.access_token, start + (data.expires_in * 1000)); this.setRefreshToken(data.refresh_token, start + (data.expires_in * 1000)); // We have just set the tokens return { // @ts-ignore accessToken: this._tokens.accessToken, // @ts-ignore refreshToken: this._tokens.refreshToken, }; } } exports.RemoteApi = RemoteApi; function getAuthenticationDetailsFromHeader(headers) { // if (!response || !response.headers) { // throw new ApiError('Response object is missing headers property'); // } if (!headers) { throw new ApiError_1.ApiError('No headers provided'); } if (!headers['www-authenticate']) { throw new ApiError_1.ApiError('Response is missing the "www-authenticate" header'); } const wwwAuthenticate = headers['www-authenticate']; const realmResult = /realm="(.*?)"/.exec(wwwAuthenticate); if (!realmResult) { throw new ApiError_1.ApiError(`Realm was not found in www-authenticate header '${wwwAuthenticate}'`); } const nonceResult = /nonce="(.*?)"/.exec(wwwAuthenticate); if (!nonceResult) { throw new ApiError_1.ApiError(`Nonce was not found in www-authenitcate header '${wwwAuthenticate}'`); } return { realm: realmResult[1], nonce: nonceResult[1], }; }