@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.
@@ -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
+ }