UNPKG

homebridge-homeconnect

Version:

A Homebridge plugin that connects Home Connect appliances to Apple HomeKit

444 lines 20 kB
// Homebridge plugin for Home Connect home appliances // Copyright © 2023-2025 Alexander Thoukydides import { setTimeout as setTimeoutP } from 'timers/promises'; import chalk from 'chalk'; import { APIUserAgent } from './api-ua.js'; import { assertIsDefined, formatMilliseconds, formatSeconds, MS } from './utils.js'; import { logError } from './log-error.js'; import { APIAuthorisationError, APIError, APIStatusCodeError } from './api-errors.js'; import { API_SCOPES } from './settings.js'; import { AuthHelpDeviceFlow } from './api-ua-auth-help.js'; import { checkers } from './ti/api-auth-types.js'; // Colours for the verification message const COLOUR_HI = chalk.greenBright; const COLOUR_LINK = chalk.bold; // An authorisation abort and retry trigger export class AuthorisationRetry { constructor(reason) { this.reason = reason; } } // Authorisation for accessing the Home Connect API export class APIAuthoriseUserAgent extends APIUserAgent { // Create a new authorisation agent constructor(log, config, persist, language) { super(log, config, language); this.persist = persist; // Time before token expiry to request a refresh this.refreshWindow = 60 * 60 * MS; // Delay between retrying failed authorisation operations this.refreshRetryDelay = 6 * MS; this.pollPersistDelay = 3 * MS; // Device Flow polling (normally set by API response) and prompt logging this.deviceFlowPollInterval = 5 * MS; this.deviceFlowLogInterval = 12 * MS; // Triggers indicating that it may be worth reattempting authorisation this.triggerAuthorisationRetry = []; this.isAuthorised = this.authoriseUserAgent(); this.maintainAccessToken(); } // Scopes that have (or will be) authorised get scopes() { return this.token?.scopes ?? API_SCOPES; } // Attempt to obtain an access token async authoriseUserAgent() { while (!this.token) { try { this.log.info('Attempting authorisation'); this.setAuthorisationStatus({ state: 'busy' }); await this.obtainAccessToken(); } catch (err) { if (err instanceof AuthorisationRetry) { this.log.info(`Restarting authorisation: ${err.reason}`); } else { logError(this.log, 'API authorisation', err); this.setAuthorisationStatusFailed(err); this.log.error('Authorisation attempt abandoned; restart Homebridge to try again'); await Promise.race(this.triggerAuthorisationRetry); } } } this.log.info('Successfully authorised'); this.setAuthorisationStatus({ state: 'success' }); } // Maintain the access token with periodic refreshes async maintainAccessToken() { // Wait until an access token has been obtained await this.isAuthorised; assertIsDefined(this.token); // Periodically refresh the access token for (;;) { // Wait until the token is due to expire, unless a failure occurs const refreshNow = new Promise(resolve => { this.triggerTokenRefresh = () => { this.log.warn('Triggering early token refresh'); this.triggerTokenRefresh = undefined; resolve(); }; }); const expiresIn = this.token.accessExpires - Date.now(); await Promise.race([setTimeoutP(expiresIn - this.refreshWindow), refreshNow]); // Refresh the token this.log.info('Refreshing access token'); this.isAuthorised = this.refreshAccessToken(); await this.isAuthorised; this.log.info('Successfully refreshed access token'); } } // Single attempt to obtain an access token async obtainAccessToken() { this.triggerAuthorisationRetry.length = 0; // Retrieve any saved authorisation let savedToken; try { savedToken = await this.getSavedToken(); if (Date.now() + this.refreshWindow < savedToken.accessExpires) { this.log.info('Using saved access token'); this.token = savedToken; } else { this.log.info('Saved access token has expired'); const token = await this.accessTokenRefreshRequest(savedToken.refreshToken); this.log.info('Successfully refreshed access token'); this.saveToken(token); } } catch (err) { const message = err instanceof Error ? err.message : String(err); this.log.info(`Unable to use saved API authorisation (${message})`); this.triggerAuthorisationRetry.push(this.watchToken(savedToken)); // Attempt new authorisation let token; if (this.config.simulator) { this.log.info('Requesting authorisation using Code Grant Flow'); token = await this.authorisationCodeGrantFlow(); } else { try { this.log.info('Requesting authorisation using the Device Flow'); const promise = new Promise(resolve => this.triggerDeviceFlow = resolve); this.triggerAuthorisationRetry.push(promise); token = await this.deviceFlow(); } catch (err) { const help = new AuthHelpDeviceFlow(err, this.config.clientid); help.log(this.log); this.setAuthorisationStatusFailed(err, true, help); throw err; } } this.saveToken(token); } } // Refresh the access token async refreshAccessToken() { assertIsDefined(this.token); for (;;) { try { const token = await this.accessTokenRefreshRequest(this.token.refreshToken); this.saveToken(token); return; } catch (cause) { logError(this.log, 'API token refresh', cause); await setTimeoutP(this.refreshRetryDelay); } } } // Attempt to retrieve a saved token async getSavedToken() { // Attempt to retrieve the appropriate token const savedTokens = await this.loadTokens(); const token = savedTokens.get(this.config.clientid); if (token === undefined) throw Error('No saved authorisation for this client'); // Notify any interested parties that authorisation is not required return token; } // Wait for any update to a saved token async watchToken(oldToken) { let token; while (!token || token.accessToken === oldToken?.accessToken) { await setTimeoutP(this.pollPersistDelay); try { token = await this.getSavedToken(); } catch { /* empty */ } } return new AuthorisationRetry('Saved token changed'); } // Apply and save a new access token async saveToken(newToken) { this.log.debug(`Refresh token ${newToken.refresh_token}`); this.log.debug(`Access token ${newToken.access_token}` + ` (expires in ${formatSeconds(newToken.expires_in)})`); // Convert the token to storage format, with an absolute expiry time this.token = { refreshToken: newToken.refresh_token, accessToken: newToken.access_token, accessExpires: Date.now() + newToken.expires_in * MS, scopes: newToken.scope.split(' ') }; // Read any previously saved tokens let savedTokens = new Map(); try { savedTokens = await this.loadTokens(); } catch (err) { const message = err instanceof Error ? err.message : String(err); this.log.debug(`Failed to load saved authorisation tokens: ${message}`); } // Replace (or add) the token for the current client savedTokens.set(this.config.clientid, this.token); const persistSavedTokens = Object.fromEntries(savedTokens); await this.persist.setItem('token', persistSavedTokens); this.log.debug('Authorisation token saved'); } // Retrieve saved tokens async loadTokens() { const savedTokens = await this.persist.getItem('token'); if (!savedTokens) throw Error('No saved authorisation data found'); if (!checkers.PersistAbsoluteTokens.test(savedTokens)) { throw Error('Incompatible saved authorisation data'); } return new Map(Object.entries(savedTokens)); } // Authorisation Code Grant Flow for simulator only async authorisationCodeGrantFlow() { // Obtain the authorisation code without user interaction const { code, redirect_uri } = await this.authorisationRequest(); // Exchange the authorisation code for an access token this.log.debug(`Using authorisation code ${code} and redirect ${redirect_uri} to request token`); return await this.accessTokenRequest(code, redirect_uri); } // Device Flow async deviceFlow() { // Obtain verification URI, using defaults for any missing parameters const response = await this.deviceAuthorisationRequest(); if (response.interval) this.deviceFlowPollInterval = response.interval * MS; // Provide the verification URI to any other interested party const expires = response.expires_in ? Date.now() + response.expires_in * MS : null; this.setAuthorisationStatus({ state: 'user', uri: response.verification_uri_complete ?? response.verification_uri, code: response.user_code, expires }); // Display the verification URI in the log file let displayPrompts = true; const logPrompt = async () => { while (displayPrompts) { const expiry = expires ? ` within ${formatMilliseconds(expires - Date.now())}` : ''; this.log.info(COLOUR_HI(`Please authorise access to your appliances${expiry}` + ' using the associated Home Connect or SingleKey ID' + ' email address by visiting:')); this.log.info(response.verification_uri_complete ? COLOUR_HI(` ${COLOUR_LINK(response.verification_uri_complete)}`) : COLOUR_HI(` ${COLOUR_LINK(response.verification_uri)} and enter code ${COLOUR_LINK(response.user_code)}`)); await setTimeoutP(this.deviceFlowLogInterval); } }; // Wait for the user to authorise access (or expiry of device code) this.log.debug('Waiting for completion of Home Connect authorisation' + ` (poll every ${formatMilliseconds(this.deviceFlowPollInterval)},` + (response.expires_in ? ` expires in ${formatSeconds(response.expires_in)},` : '') + ` device code ${response.device_code})...`); try { logPrompt(); this.pollDeviceCode = response.device_code; const token = await Promise.race([this.deviceAccessTokenRequest(response.device_code), ...this.triggerAuthorisationRetry]); // eslint-disable-next-line @typescript-eslint/only-throw-error if (token instanceof AuthorisationRetry) throw token; return token; } finally { this.pollDeviceCode = undefined; displayPrompts = false; } } // Trigger a retry of Device Flow authorisation retryDeviceFlow(reason = 'User requested retry') { this.triggerDeviceFlow?.(new AuthorisationRetry(reason)); } // Update the authorisation status with an error setAuthorisationStatusFailed(err, retryable = false, help) { // Ignore duplicates with the same error object and retry requests if (this.status.state === 'fail' && this.status.error === err) return; if (err instanceof AuthorisationRetry) return; // Log the error const message = err instanceof Error ? err.message : String(err); const status = { state: 'fail', error: err, message, retryable }; if (help) status.help = help.getStructured(); this.setAuthorisationStatus(status); } // Update the authorisation status setAuthorisationStatus(status) { this.status = status; this.triggerStatusUpdate?.(); this.statusUpdate = new Promise(resolve => this.triggerStatusUpdate = resolve); } // Get authorisation status updates async getAuthorisationStatus(immediate = false) { if (!immediate) await this.statusUpdate; return this.status; } // Authorisation Code Grant Flow: Automatic authorisation for simulator only async authorisationRequest() { const requestURL = this.authorisationRequestURL(); requestURL.searchParams.append('user', 'me'); // (anything non-zero length works) const redirectURL = await this.getRedirect(requestURL.pathname + requestURL.search); // Parse the redirect location const response = this.parseAuthorisationResponse(redirectURL); // Remove the code from the redirect URL redirectURL.searchParams.delete('code'); const redirect_uri = redirectURL.href; return { ...response, redirect_uri }; } // Authorisation Code Grant Flow: Generate URL to open in a browser authorisationRequestURL(redirect_uri) { const query = { ...{ client_id: this.config.clientid, response_type: 'code', scope: API_SCOPES.join(' ') } }; if (redirect_uri) query.redirect_uri = redirect_uri; const url = new URL('/security/oauth/authorize', this.url); const searchParams = query; url.search = new URLSearchParams(searchParams).toString(); return url; } // Authorisation Code Grant Flow: Parse redirect_uri providing a code parseAuthorisationResponse(url) { // Extract the query parameters from the URL if (typeof url === 'string') url = new URL(url, this.url); const response = Object.fromEntries(url.searchParams); // First check whether this is an error response const request = { method: 'GET', path: url.pathname + url.search, headers: {} }; if (checkers.AuthorisationError.test(response)) { throw new APIAuthorisationError(request, undefined, `Home Connect API Redirect Error: ${response.error_description} [${response.error}]`); } // Check that the query parameters have the expected values const checker = checkers.AuthorisationResponse; checker.setReportedPath('redirect_uri'); if (!checker.test(response)) { const validation = checker.validate(response) ?? []; this.logCheckerValidation("error" /* LogLevel.ERROR */, 'Unexpected structure of Home Connect API redirect_uri', undefined, validation, response); throw new APIAuthorisationError(request, undefined, 'Validation of redirect_uri failed'); } const strictValidation = checker.strictValidate(response); if (strictValidation) { this.logCheckerValidation("warn" /* LogLevel.WARN */, 'Unexpected name-value pairs in Home Connect API redirect_uri', undefined, strictValidation, response); } // Return the parsed response return response; } // Authorisation Code Grant Flow: Exchange code for an access token accessTokenRequest(code, redirect_uri) { const postForm = { client_id: this.config.clientid, grant_type: 'authorization_code', code }; if (this.config.clientsecret) postForm.client_secret = this.config.clientsecret; if (redirect_uri) postForm.redirect_uri = redirect_uri; return this.post(checkers.AccessTokenResponse, '/security/oauth/token', postForm); } // Device Flow: Request a URL that the user can use to authorise this device deviceAuthorisationRequest() { const postForm = { client_id: this.config.clientid, scope: API_SCOPES.join(' ') }; return this.post(checkers.DeviceAuthorisationResponse, '/security/oauth/device_authorization', postForm); } // Device Flow: Wait for user authorisation to obtain an access token deviceAccessTokenRequest(device_code) { const postForm = { client_id: this.config.clientid, grant_type: 'device_code', device_code }; if (this.config.clientsecret) postForm.client_secret = this.config.clientsecret; return this.post(checkers.DeviceAccessTokenResponse, '/security/oauth/token', postForm); } // Refresh an access token accessTokenRefreshRequest(refresh_token) { const postForm = { grant_type: 'refresh_token', refresh_token }; if (this.config.clientsecret) postForm.client_secret = this.config.clientsecret; return this.post(checkers.AccessTokenRefreshResponse, '/security/oauth/token', postForm); } // Construct an Authorisation header get authorizationHeader() { return this.token && `Bearer ${this.token.accessToken}`; } // Add an Authorisation header to request options async prepareRequest(method, path, options) { const request = await super.prepareRequest(method, path, options); // Wait for client to be authorised, unless this is an authorisation request if (!path.startsWith('/security/oauth/')) { try { // Wait for authorisation and then set the Authorisation header await this.isAuthorised; assertIsDefined(this.authorizationHeader); request.headers.authorization = this.authorizationHeader; } catch (err) { if (!(err instanceof APIError)) throw err; const cause = err.errCause; throw new APIAuthorisationError(err.request, err.response, err.message, { cause }); } } // Return the modified request options return request; } // Restart authorisation if a request indicated an invalid token canRetry(err) { // Authorisation issues take priority over other failure cases if (err instanceof APIStatusCodeError && err.response) { switch (err.response.statusCode) { case 400: if (err.key === 'authorization_pending' && this.pollDeviceCode && err.request.body?.includes(this.pollDeviceCode)) { // Device Flow user interaction steps not completed this.retryDelay = this.deviceFlowPollInterval; return true; } break; case 401: // Access token is probably missing/malformed/expired/revoked if (err.request.headers.authorization === this.authorizationHeader) { this.triggerTokenRefresh?.(); } break; } } // Apply standard checks for retrying the request return super.canRetry(err); } } //# sourceMappingURL=api-ua-auth.js.map