@mdjhnson/homebridge-yoto 0.1.0
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.
- package/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Custom UI server for Yoto Homebridge plugin OAuth authentication
|
|
3
|
+
*
|
|
4
|
+
* Uses the OAuth 2.0 Authorization Code flow with PKCE. The user signs in on
|
|
5
|
+
* Yoto's site, lands on the (unreachable) loopback redirect URI, and pastes that
|
|
6
|
+
* address back into the plugin settings. The PKCE verifier never leaves this
|
|
7
|
+
* server, so the pasted code is useless to anyone else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { HomebridgePluginUiServer, RequestError } from '@homebridge/plugin-ui-utils'
|
|
11
|
+
import { YotoClient } from 'yoto-nodejs-client'
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_CLIENT_ID,
|
|
14
|
+
LEGACY_CLIENT_IDS,
|
|
15
|
+
OAUTH_REDIRECT_URI,
|
|
16
|
+
OAUTH_SCOPES,
|
|
17
|
+
} from '../lib/settings.js'
|
|
18
|
+
import { createOAuthState, createPkcePair, parseAuthorizationResponse } from '../lib/utils/oauth.js'
|
|
19
|
+
|
|
20
|
+
const AUDIENCE = 'https://api.yotoplay.com'
|
|
21
|
+
/** Sign-in attempts are discarded after this long */
|
|
22
|
+
const PENDING_AUTH_TTL_MS = 15 * 60 * 1000
|
|
23
|
+
/** Used when the token response has no expires_in; the plugin refreshes early anyway */
|
|
24
|
+
const DEFAULT_EXPIRES_IN_S = 60 * 60
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} PendingAuth
|
|
28
|
+
* @property {string} codeVerifier
|
|
29
|
+
* @property {string} clientId
|
|
30
|
+
* @property {number} createdAt
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @type {Map<string, PendingAuth>} */
|
|
34
|
+
const pendingAuths = new Map()
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Custom UI server for Yoto plugin OAuth authentication
|
|
38
|
+
* @extends {HomebridgePluginUiServer}
|
|
39
|
+
*/
|
|
40
|
+
class YotoUiServer extends HomebridgePluginUiServer {
|
|
41
|
+
constructor () {
|
|
42
|
+
// super() MUST be called first
|
|
43
|
+
super()
|
|
44
|
+
|
|
45
|
+
// Register OAuth endpoints
|
|
46
|
+
this.onRequest('/auth/config', getAuthConfig)
|
|
47
|
+
this.onRequest('/auth/start', startAuthorization)
|
|
48
|
+
this.onRequest('/auth/exchange', exchangeAuthorizationCode)
|
|
49
|
+
|
|
50
|
+
// this MUST be called when you are ready to accept requests
|
|
51
|
+
this.ready()
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Create and start the server
|
|
56
|
+
(() => new YotoUiServer())()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Response from /auth/config endpoint
|
|
60
|
+
* @typedef {Object} AuthConfigResponse
|
|
61
|
+
* @property {string} defaultClientId - The default OAuth client ID
|
|
62
|
+
* @property {string[]} legacyClientIds - Client IDs that no longer support sign-in
|
|
63
|
+
* @property {string} redirectUri - Redirect URI to register on a custom Yoto app
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get authentication configuration
|
|
68
|
+
* @returns {Promise<AuthConfigResponse>}
|
|
69
|
+
*/
|
|
70
|
+
async function getAuthConfig () {
|
|
71
|
+
return {
|
|
72
|
+
defaultClientId: DEFAULT_CLIENT_ID,
|
|
73
|
+
legacyClientIds: LEGACY_CLIENT_IDS,
|
|
74
|
+
redirectUri: OAUTH_REDIRECT_URI,
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Request payload for /auth/start endpoint
|
|
80
|
+
* @typedef {Object} AuthStartRequest
|
|
81
|
+
* @property {string} [clientId] - OAuth client ID from config (optional, falls back to DEFAULT_CLIENT_ID)
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Response from /auth/start endpoint
|
|
86
|
+
* @typedef {Object} AuthStartResponse
|
|
87
|
+
* @property {string} authorizeUrl - Yoto sign-in URL to open in the browser
|
|
88
|
+
* @property {string} state - Identifies this sign-in attempt
|
|
89
|
+
* @property {string} clientId - OAuth client ID used for this attempt
|
|
90
|
+
*/
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Start an Authorization Code + PKCE sign-in
|
|
94
|
+
* @param {AuthStartRequest} payload - Request with optional client ID
|
|
95
|
+
* @returns {Promise<AuthStartResponse>}
|
|
96
|
+
*/
|
|
97
|
+
async function startAuthorization (payload) {
|
|
98
|
+
prunePendingAuths()
|
|
99
|
+
|
|
100
|
+
const requested = typeof payload?.clientId === 'string' ? payload.clientId.trim() : ''
|
|
101
|
+
const clientId = requested && !LEGACY_CLIENT_IDS.includes(requested) ? requested : DEFAULT_CLIENT_ID
|
|
102
|
+
const { codeVerifier, codeChallenge } = createPkcePair()
|
|
103
|
+
const state = createOAuthState()
|
|
104
|
+
|
|
105
|
+
pendingAuths.set(state, { codeVerifier, clientId, createdAt: Date.now() })
|
|
106
|
+
|
|
107
|
+
const authorizeUrl = YotoClient.getAuthorizeUrl({
|
|
108
|
+
audience: AUDIENCE,
|
|
109
|
+
scope: OAUTH_SCOPES,
|
|
110
|
+
responseType: 'code',
|
|
111
|
+
clientId,
|
|
112
|
+
redirectUri: OAUTH_REDIRECT_URI,
|
|
113
|
+
state,
|
|
114
|
+
codeChallenge,
|
|
115
|
+
codeChallengeMethod: 'S256',
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
console.log('[Server] Started sign-in with clientId:', clientId)
|
|
119
|
+
return { authorizeUrl, state, clientId }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Error payload for /auth/exchange failures
|
|
124
|
+
* @typedef {Object} AuthExchangeError
|
|
125
|
+
* @property {string} message - Message to show the user
|
|
126
|
+
* @property {boolean} restart - True when this sign-in attempt can't continue and a new one must be started
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Throw an /auth/exchange error
|
|
131
|
+
* @param {string} title
|
|
132
|
+
* @param {string} message
|
|
133
|
+
* @param {boolean} restart - Whether the user must start a new sign-in
|
|
134
|
+
* @returns {never}
|
|
135
|
+
*/
|
|
136
|
+
function throwExchangeError (title, message, restart) {
|
|
137
|
+
/** @type {AuthExchangeError} */
|
|
138
|
+
const body = { message, restart }
|
|
139
|
+
throw new RequestError(title, body)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Request payload for /auth/exchange endpoint
|
|
144
|
+
* @typedef {Object} AuthExchangeRequest
|
|
145
|
+
* @property {string} state - State returned by /auth/start
|
|
146
|
+
* @property {string} response - Pasted redirect address (or bare code)
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Response from /auth/exchange endpoint
|
|
151
|
+
* @typedef {Object} AuthExchangeResponse
|
|
152
|
+
* @property {string} refreshToken - OAuth refresh token (long-lived)
|
|
153
|
+
* @property {string} accessToken - OAuth access token (short-lived)
|
|
154
|
+
* @property {number} tokenExpiresAt - Unix timestamp in ms when the access token expires
|
|
155
|
+
* @property {string} clientId - OAuth client ID the tokens belong to
|
|
156
|
+
*/
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Exchange the pasted authorization response for tokens
|
|
160
|
+
* @param {AuthExchangeRequest} payload
|
|
161
|
+
* @returns {Promise<AuthExchangeResponse>}
|
|
162
|
+
*/
|
|
163
|
+
async function exchangeAuthorizationCode (payload) {
|
|
164
|
+
prunePendingAuths()
|
|
165
|
+
|
|
166
|
+
const pending = payload?.state ? pendingAuths.get(payload.state) : undefined
|
|
167
|
+
if (!pending) {
|
|
168
|
+
throwExchangeError('Sign-in expired', 'This sign-in attempt has expired. Click "Sign in with Yoto" to start again.', true)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const parsed = parseAuthorizationResponse(typeof payload.response === 'string' ? payload.response : '')
|
|
172
|
+
|
|
173
|
+
// Check state first, so an address from another attempt can't cancel this one.
|
|
174
|
+
// A bare pasted code has no state; PKCE still ties it to this attempt's verifier.
|
|
175
|
+
if (parsed.state && parsed.state !== payload.state) {
|
|
176
|
+
throwExchangeError(
|
|
177
|
+
'Sign-in mismatch',
|
|
178
|
+
'That address is from a different sign-in attempt. Use the link from your most recent "Sign in with Yoto" click.',
|
|
179
|
+
false
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (parsed.error) {
|
|
184
|
+
pendingAuths.delete(payload.state)
|
|
185
|
+
const message = parsed.error === 'access_denied'
|
|
186
|
+
? 'Access was denied. Click "Sign in with Yoto" to try again.'
|
|
187
|
+
: `Yoto returned an error: ${parsed.errorDescription || parsed.error}`
|
|
188
|
+
throwExchangeError('Sign-in failed', message, true)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!parsed.code) {
|
|
192
|
+
throwExchangeError(
|
|
193
|
+
'Missing code',
|
|
194
|
+
'Paste the full address from your browser after signing in. It starts with http://127.0.0.1:8787/callback?code=',
|
|
195
|
+
false
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const tokens = await YotoClient.exchangeToken({
|
|
201
|
+
grantType: 'authorization_code',
|
|
202
|
+
code: parsed.code,
|
|
203
|
+
redirectUri: OAUTH_REDIRECT_URI,
|
|
204
|
+
codeVerifier: pending.codeVerifier,
|
|
205
|
+
clientId: pending.clientId,
|
|
206
|
+
audience: AUDIENCE,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
if (!tokens.refresh_token || !tokens.access_token) {
|
|
210
|
+
throw new Error('Token response missing required fields. Make sure offline_access is enabled on the Yoto app.')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
pendingAuths.delete(payload.state)
|
|
214
|
+
console.log('[Server] Token exchange successful')
|
|
215
|
+
|
|
216
|
+
const expiresIn = Number(tokens.expires_in)
|
|
217
|
+
return {
|
|
218
|
+
refreshToken: tokens.refresh_token,
|
|
219
|
+
accessToken: tokens.access_token,
|
|
220
|
+
tokenExpiresAt: Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : DEFAULT_EXPIRES_IN_S) * 1000,
|
|
221
|
+
clientId: pending.clientId,
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const err = /** @type {{ jsonBody?: { error?: string, error_description?: string }, textBody?: string }} */ (error)
|
|
225
|
+
const description = err.jsonBody?.error_description || err.jsonBody?.error || err.textBody
|
|
226
|
+
const message = description || (error instanceof Error ? error.message : String(error))
|
|
227
|
+
console.error('[Server] Token exchange failed:', message)
|
|
228
|
+
if (err.jsonBody?.error === 'invalid_grant') {
|
|
229
|
+
throwExchangeError(
|
|
230
|
+
'Token exchange failed',
|
|
231
|
+
'That sign-in code was already used or has expired. Click "Sign in with Yoto" to start again.',
|
|
232
|
+
true
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
throwExchangeError('Token exchange failed', message, false)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Drop sign-in attempts older than PENDING_AUTH_TTL_MS
|
|
241
|
+
*/
|
|
242
|
+
function prunePendingAuths () {
|
|
243
|
+
const cutoff = Date.now() - PENDING_AUTH_TTL_MS
|
|
244
|
+
for (const [state, pending] of pendingAuths) {
|
|
245
|
+
if (pending.createdAt < cutoff) pendingAuths.delete(state)
|
|
246
|
+
}
|
|
247
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Homebridge Yoto Plugin Entry Point
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @import { API } from 'homebridge' */
|
|
6
|
+
|
|
7
|
+
import { YotoPlatform } from './lib/platform.js'
|
|
8
|
+
import { PLATFORM_NAME } from './lib/settings.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Register the Yoto platform with Homebridge
|
|
12
|
+
* @param {API} api - Homebridge API
|
|
13
|
+
*/
|
|
14
|
+
export default function (api) {
|
|
15
|
+
api.registerPlatform(PLATFORM_NAME, YotoPlatform)
|
|
16
|
+
}
|