UNPKG

node-hue-api

Version:
259 lines (257 loc) 10.7 kB
import * as httpClient from './HttpClientFetch'; import { ApiError } from '../../ApiError'; import { OAuthTokens } from './OAuthTokens'; import { wasSuccessful } from '../../util'; import { createHash } from '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. export class RemoteApi { constructor(clientId, clientSecret) { this._config = { clientId: clientId, clientSecret: clientSecret, baseUrl: new URL('https://api.meethue.com'), }; this._tokens = new 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 = createHash('md5').update(`${clientId}:${realm}:${clientSecret}`).digest('hex'), hashTwo = createHash('md5').update(`${method.toUpperCase()}:${path}`).digest('hex'), hash = createHash('md5').update(`${hashOne}:${nonce}:${hashTwo}`).digest('hex'); if (!clientId) { throw new ApiError('clientId has not been provided, unable to build a digest response'); } if (!clientSecret) { throw new 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(`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(`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('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 (!wasSuccessful(res.data)) { throw new 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 (wasSuccessful(res.data)) { return res.data[0].success.username; } else { throw new 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(`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, }; } } function getAuthenticationDetailsFromHeader(headers) { // if (!response || !response.headers) { // throw new ApiError('Response object is missing headers property'); // } if (!headers) { throw new ApiError('No headers provided'); } if (!headers['www-authenticate']) { throw new ApiError('Response is missing the "www-authenticate" header'); } const wwwAuthenticate = headers['www-authenticate']; const realmResult = /realm="(.*?)"/.exec(wwwAuthenticate); if (!realmResult) { throw new ApiError(`Realm was not found in www-authenticate header '${wwwAuthenticate}'`); } const nonceResult = /nonce="(.*?)"/.exec(wwwAuthenticate); if (!nonceResult) { throw new ApiError(`Nonce was not found in www-authenitcate header '${wwwAuthenticate}'`); } return { realm: realmResult[1], nonce: nonceResult[1], }; }