homebridge-yoto 0.0.27 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * @fileoverview Custom UI server for Yoto Homebridge plugin OAuth authentication
3
+ */
4
+
5
+ import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils'
6
+ import { YotoClient } from 'yoto-nodejs-client'
7
+ import { DEFAULT_CLIENT_ID } from '../lib/settings.js'
8
+
9
+ /**
10
+ * Custom UI server for Yoto plugin OAuth authentication
11
+ * @extends {HomebridgePluginUiServer}
12
+ */
13
+ class YotoUiServer extends HomebridgePluginUiServer {
14
+ constructor () {
15
+ // super() MUST be called first
16
+ super()
17
+
18
+ // Register OAuth endpoints
19
+ this.onRequest('/auth/config', getAuthConfig)
20
+ this.onRequest('/auth/start', startDeviceFlow)
21
+ this.onRequest('/auth/poll', pollForToken)
22
+
23
+ // this MUST be called when you are ready to accept requests
24
+ this.ready()
25
+ }
26
+ }
27
+
28
+ // Create and start the server
29
+ (() => new YotoUiServer())()
30
+
31
+ /**
32
+ * Response from /auth/config endpoint
33
+ * @typedef {Object} AuthConfigResponse
34
+ * @property {string} defaultClientId - The default OAuth client ID
35
+ */
36
+
37
+ /**
38
+ * Get authentication configuration
39
+ * @returns {Promise<AuthConfigResponse>}
40
+ */
41
+ async function getAuthConfig () {
42
+ return {
43
+ defaultClientId: DEFAULT_CLIENT_ID
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Request payload for /auth/start endpoint
49
+ * @typedef {Object} AuthStartRequest
50
+ * @property {string} [clientId] - OAuth client ID from config (optional, falls back to DEFAULT_CLIENT_ID)
51
+ */
52
+
53
+ /**
54
+ * Response from /auth/start endpoint
55
+ * @typedef {Object} AuthStartResponse
56
+ * @property {string} verification_uri - Base URL for user verification (e.g., https://yotoplay.com/activate)
57
+ * @property {string} verification_uri_complete - Complete URL with code pre-filled
58
+ * @property {string} user_code - User code to enter at verification_uri
59
+ * @property {string} device_code - Device code for polling (not shown to user)
60
+ * @property {number} expires_in - Seconds until code expires (typically 900)
61
+ * @property {number} interval - Recommended polling interval in seconds
62
+ * @property {string} client_id - OAuth client ID used for this flow
63
+ */
64
+
65
+ /**
66
+ * Start OAuth device flow
67
+ * @param {AuthStartRequest} payload - Request with optional client ID
68
+ * @returns {Promise<AuthStartResponse>}
69
+ */
70
+ async function startDeviceFlow (payload) {
71
+ console.log('[Server] startDeviceFlow called with payload:', JSON.stringify(redactSensitive(payload), null, 2))
72
+ try {
73
+ const clientId = payload.clientId || DEFAULT_CLIENT_ID
74
+ console.log('[Server] Using clientId:', clientId)
75
+
76
+ // Request device code from Yoto
77
+ console.log('[Server] Requesting device code from Yoto API...')
78
+ const deviceCodeResponse = await YotoClient.requestDeviceCode({
79
+ clientId,
80
+ scope: 'openid profile offline_access',
81
+ audience: 'https://api.yotoplay.com'
82
+ })
83
+ console.log('[Server] Device code response:', JSON.stringify(redactSensitive(deviceCodeResponse), null, 2))
84
+
85
+ // Return the device flow info to the UI
86
+ const result = {
87
+ verification_uri: deviceCodeResponse.verification_uri || '',
88
+ verification_uri_complete: deviceCodeResponse.verification_uri_complete || '',
89
+ user_code: deviceCodeResponse.user_code || '',
90
+ device_code: deviceCodeResponse.device_code || '',
91
+ expires_in: deviceCodeResponse.expires_in || 900,
92
+ interval: deviceCodeResponse.interval || 5,
93
+ client_id: clientId
94
+ }
95
+ console.log('[Server] startDeviceFlow returning:', JSON.stringify(result, null, 2))
96
+ return result
97
+ } catch (error) {
98
+ console.error('[Server] startDeviceFlow error:', error)
99
+ throw new RequestError('Failed to start device flow', {
100
+ message: error instanceof Error ? error.message : 'Unknown error'
101
+ })
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Request payload for /auth/poll endpoint
107
+ * @typedef {Object} AuthPollRequest
108
+ * @property {string} deviceCode - Device code from AuthStartResponse
109
+ * @property {string} clientId - OAuth client ID used in auth/start
110
+ */
111
+
112
+ /**
113
+ * Response from /auth/poll endpoint when still pending
114
+ * @typedef {Object} AuthPollPendingResponse
115
+ * @property {true} pending - Indicates authorization still pending
116
+ * @property {string} message - Status message (e.g., "Waiting for authorization...")
117
+ */
118
+
119
+ /**
120
+ * Response from /auth/poll endpoint when need to slow down
121
+ * @typedef {Object} AuthPollSlowDownResponse
122
+ * @property {true} slow_down - Indicates polling too fast
123
+ * @property {string} message - Status message about slowing down
124
+ * @property {number} [interval] - Updated polling interval (in seconds)
125
+ */
126
+
127
+ /**
128
+ * Response from /auth/poll endpoint on success
129
+ * @typedef {Object} AuthPollSuccessResponse
130
+ * @property {true} success - Indicates successful authentication
131
+ * @property {string} message - Success message
132
+ * @property {string} refreshToken - OAuth refresh token (long-lived)
133
+ * @property {string} accessToken - OAuth access token (short-lived)
134
+ * @property {number} tokenExpiresAt - Unix timestamp when access token expires
135
+ */
136
+
137
+ /**
138
+ * Union type for all possible /auth/poll responses
139
+ * @typedef {AuthPollPendingResponse | AuthPollSlowDownResponse | AuthPollSuccessResponse} AuthPollResponse
140
+ */
141
+
142
+ /**
143
+ * Poll for token exchange
144
+ * @param {AuthPollRequest} payload - Request payload with device code and client ID
145
+ * @returns {Promise<AuthPollResponse>}
146
+ */
147
+ async function pollForToken (payload) {
148
+ console.log('[Server] pollForToken called with payload:', JSON.stringify(redactSensitive(payload), null, 2))
149
+ const { deviceCode, clientId } = payload
150
+
151
+ if (!deviceCode || !clientId) {
152
+ console.error('[Server] Missing deviceCode or clientId')
153
+ throw new RequestError('Missing required parameters', {
154
+ message: 'deviceCode and clientId are required'
155
+ })
156
+ }
157
+
158
+ try {
159
+ // Poll for device token using helper function
160
+ console.log('[Server] Polling for device token...')
161
+ const pollResult = await YotoClient.pollForDeviceToken({
162
+ deviceCode,
163
+ clientId,
164
+ audience: 'https://api.yotoplay.com'
165
+ })
166
+
167
+ // Check if authorization is still pending
168
+ if (pollResult.status === 'pending') {
169
+ console.log('[Server] Authorization still pending...')
170
+ /** @type {AuthPollPendingResponse} */
171
+ const pendingResult = {
172
+ pending: true,
173
+ message: 'Waiting for authorization...'
174
+ }
175
+ return pendingResult
176
+ }
177
+
178
+ // Check if we need to slow down polling
179
+ if (pollResult.status === 'slow_down') {
180
+ const intervalSeconds = pollResult.interval / 1000 // pollResult.interval is in milliseconds, convert to seconds
181
+ console.log('[Server] Polling too fast, slowing down to interval:', intervalSeconds, 'seconds')
182
+ /** @type {AuthPollSlowDownResponse} */
183
+ const slowDownResult = {
184
+ slow_down: true,
185
+ message: 'Polling too fast, slowing down...',
186
+ interval: intervalSeconds // Client expects seconds
187
+ }
188
+ return slowDownResult
189
+ }
190
+
191
+ // Success - we got tokens (status === 'success')
192
+ console.log('[Server] Token exchange successful!')
193
+
194
+ // Validate required token fields
195
+ if (!pollResult.tokens.refresh_token || !pollResult.tokens.access_token) {
196
+ throw new Error('Token response missing required fields')
197
+ }
198
+
199
+ // Calculate token expiration
200
+ const tokenExpiresAt = Date.now() + (pollResult.tokens.expires_in * 1000)
201
+
202
+ // Return tokens to client for saving
203
+ /** @type {AuthPollSuccessResponse} */
204
+ const result = {
205
+ success: true,
206
+ message: 'Authentication successful!',
207
+ refreshToken: pollResult.tokens.refresh_token,
208
+ accessToken: pollResult.tokens.access_token,
209
+ tokenExpiresAt
210
+ }
211
+ console.log('[Server] pollForToken success, returning tokens (redacted)')
212
+ return result
213
+ } catch (error) {
214
+ // Handle errors from pollForDeviceToken
215
+ const err = /** @type {any} */ (error)
216
+ const errorCode = err.body?.error
217
+ const errorDescription = err.body?.error_description
218
+
219
+ if (errorCode === 'expired_token') {
220
+ console.error('[Server] Device code expired')
221
+ throw new RequestError('Device code expired', {
222
+ message: 'The authorization code has expired. Please start over.'
223
+ })
224
+ }
225
+
226
+ if (errorCode === 'access_denied') {
227
+ console.error('[Server] Access denied by user')
228
+ throw new RequestError('Access denied', {
229
+ message: 'Authorization was denied. Please try again.'
230
+ })
231
+ }
232
+
233
+ // Unexpected error - log full details
234
+ console.error('[Server] Unexpected error during token poll:', error)
235
+ console.error('[Server] Error code:', errorCode)
236
+ console.error('[Server] Error description:', errorDescription)
237
+ const errorMessage = errorDescription || (error instanceof Error ? error.message : String(error))
238
+ throw new RequestError('Token exchange failed', {
239
+ message: errorMessage || 'Unknown error occurred'
240
+ })
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Redact sensitive data from objects for logging
246
+ * @param {any} obj - Object to redact
247
+ * @returns {any} Redacted copy
248
+ */
249
+ function redactSensitive (obj) {
250
+ if (!obj || typeof obj !== 'object') return obj
251
+
252
+ const redacted = Array.isArray(obj) ? [...obj] : { ...obj }
253
+ const sensitiveKeys = ['accessToken', 'refreshToken', 'access_token', 'refresh_token', 'token', 'deviceCode', 'device_code']
254
+
255
+ for (const key in redacted) {
256
+ if (sensitiveKeys.includes(key) && redacted[key]) {
257
+ redacted[key] = '[REDACTED]'
258
+ } else if (key === 'config' && typeof redacted[key] === 'object') {
259
+ redacted[key] = redactSensitive(redacted[key])
260
+ }
261
+ }
262
+
263
+ return redacted
264
+ }
package/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  /** @import { API } from 'homebridge' */
6
6
 
7
7
  import { YotoPlatform } from './lib/platform.js'
8
- import { PLATFORM_NAME } from './lib/constants.js'
8
+ import { PLATFORM_NAME } from './lib/settings.js'
9
9
 
10
10
  /**
11
11
  * Register the Yoto platform with Homebridge
package/index.test.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { test } from 'node:test'
2
- import assert from 'node:assert'
2
+ import assert from 'node:assert/strict'
3
3
  import homebridgeYoto from './index.js'
4
4
 
5
5
  test('exports default function', () => {