@camstack/addon-export-alexa 1.2.132 → 1.2.134
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/dist/export-alexa.addon.js +858 -307
- package/dist/export-alexa.addon.mjs +859 -307
- package/lambda-template/README.md +166 -103
- package/lambda-template/aws.js +37 -0
- package/lambda-template/broker.js +382 -0
- package/lambda-template/http.js +174 -0
- package/lambda-template/index.js +330 -379
- package/lambda-template/secrets.js +105 -0
- package/lambda-template/token-store.js +142 -0
- package/lambda-template/util.js +181 -0
- package/package.json +1 -1
package/lambda-template/index.js
CHANGED
|
@@ -1,75 +1,147 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* CamStack Alexa Smart Home Lambda —
|
|
2
|
+
* CamStack Alexa Smart Home Lambda — one skill, many hubs, and the skill's
|
|
3
|
+
* token broker (D753).
|
|
3
4
|
*
|
|
4
|
-
* One Lambda + one skill
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
5
|
+
* One Lambda + one skill serves every CamStack hub. Directives are routed by
|
|
6
|
+
* the `hubUrl` claim each hub bakes into the OAuth tokens it mints. Since D753
|
|
7
|
+
* the Lambda is also the ONLY holder of the skill's LWA client secret: it
|
|
8
|
+
* exchanges AcceptGrant codes itself, keeps each user's LWA refresh token in
|
|
9
|
+
* DynamoDB, and hands hubs short-lived Event Gateway access tokens against a
|
|
10
|
+
* per-link credential (see broker.js).
|
|
8
11
|
*
|
|
9
12
|
* Invocation shapes:
|
|
10
|
-
* 1.
|
|
11
|
-
* 2.
|
|
12
|
-
* 3.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* 1. Function URL GET /authorize → hub-URL entry form
|
|
14
|
+
* 2. Function URL POST /token → OAuth token exchange, proxied to the hub
|
|
15
|
+
* 3. Function URL POST /broker/* → token broker (broker.js)
|
|
16
|
+
* 4. Smart Home directive:
|
|
17
|
+
* - Alexa.Authorization/AcceptGrant → exchange the LWA code here, store the
|
|
18
|
+
* grant, give the hub a broker credential (legacy: give it the tokens)
|
|
19
|
+
* - everything else → forward to the hub
|
|
16
20
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
21
|
+
* Configuration (env — none of these is a secret once D753 is deployed):
|
|
22
|
+
* LWA_SECRET_ID Secrets Manager id of {"clientId","clientSecret"} for
|
|
23
|
+
* the skill's "Send Alexa Events" LWA client.
|
|
24
|
+
* SKILL_SECRET_ID Secrets Manager id of {"clientId","clientSecret"
|
|
25
|
+
* [,"previousClientSecret"]} for account linking.
|
|
26
|
+
* TOKENS_TABLE DynamoDB table of broker links. Unset = broker off
|
|
27
|
+
* (pre-D753 behaviour: tokens are handed to the hub).
|
|
28
|
+
* LAMBDA_REDIRECT_URI Function URL + "/token"; the AcceptGrant redirect_uri.
|
|
29
|
+
* BROKER_PUBLIC_URL Optional; defaults to LAMBDA_REDIRECT_URI's origin.
|
|
30
|
+
* LEGACY_HUB_FALLBACK "0" = a hub without the broker route fails AcceptGrant
|
|
31
|
+
* instead of receiving the refresh token. Default on.
|
|
32
|
+
* ALLOW_MIGRATION "0" = refuse /broker/migrate. Default on.
|
|
33
|
+
* HUB_INGRESS_TOKEN Optional defense-in-depth header for hub calls.
|
|
34
|
+
* Legacy (read only when the *_SECRET_ID is unset): LWA_CLIENT_ID,
|
|
35
|
+
* LWA_CLIENT_SECRET, SKILL_CLIENT_ID, SKILL_CLIENT_SECRET.
|
|
31
36
|
*
|
|
32
|
-
* Ships as
|
|
33
|
-
* AWS
|
|
37
|
+
* Ships as plain files — no node_modules, no transpile (`zip` the folder's
|
|
38
|
+
* .js files). AWS SDK v3 comes with the nodejs22.x runtime.
|
|
34
39
|
*/
|
|
40
|
+
'use strict'
|
|
35
41
|
|
|
36
|
-
const
|
|
42
|
+
const crypto = require('node:crypto')
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
44
|
+
const { createAwsClients } = require('./aws.js')
|
|
45
|
+
const {
|
|
46
|
+
activateLink,
|
|
47
|
+
createLink,
|
|
48
|
+
exchangeGrantCode,
|
|
49
|
+
handleBrokerRoute,
|
|
50
|
+
isBrokerPath,
|
|
51
|
+
shortId,
|
|
52
|
+
} = require('./broker.js')
|
|
53
|
+
const { timedFetch } = require('./http.js')
|
|
54
|
+
const { createSecretSource } = require('./secrets.js')
|
|
55
|
+
const { createDynamoTokenStore } = require('./token-store.js')
|
|
56
|
+
const {
|
|
57
|
+
decodeBody,
|
|
58
|
+
acceptGrantErrorEnvelope,
|
|
59
|
+
completeErrorResponse,
|
|
60
|
+
decodeJwtPayload,
|
|
61
|
+
describe,
|
|
62
|
+
encodeFormUrlEncoded,
|
|
63
|
+
errorEnvelope,
|
|
64
|
+
jsonResponse,
|
|
65
|
+
normaliseHubUrl,
|
|
66
|
+
parseFormUrlEncoded,
|
|
67
|
+
resolveBrokerPublicUrl,
|
|
68
|
+
} = require('./util.js')
|
|
48
69
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
/** The header that tells a hub where its skill's broker lives (D753). */
|
|
71
|
+
const BROKER_HEADER = 'x-camstack-broker'
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the dependency set once per container — the secret cache and the SDK
|
|
75
|
+
* clients must survive across invocations. Anything passed in wins (tests).
|
|
76
|
+
*/
|
|
77
|
+
function resolveDeps(overrides) {
|
|
78
|
+
const o = overrides || {}
|
|
79
|
+
const env = o.env || process.env
|
|
80
|
+
let aws = null
|
|
81
|
+
const awsClients = () => {
|
|
82
|
+
if (!aws) aws = createAwsClients()
|
|
83
|
+
return aws
|
|
84
|
+
}
|
|
85
|
+
const store =
|
|
86
|
+
o.store !== undefined
|
|
87
|
+
? o.store
|
|
88
|
+
: env.TOKENS_TABLE
|
|
89
|
+
? createDynamoTokenStore({
|
|
90
|
+
send: (name, input) => awsClients().send(name, input),
|
|
91
|
+
tableName: env.TOKENS_TABLE,
|
|
92
|
+
})
|
|
93
|
+
: null
|
|
94
|
+
return {
|
|
95
|
+
env,
|
|
96
|
+
fetch: o.fetch || timedFetch,
|
|
97
|
+
now: o.now || Date.now,
|
|
98
|
+
randomBytes: o.randomBytes || crypto.randomBytes,
|
|
99
|
+
log: o.log || console,
|
|
100
|
+
secrets:
|
|
101
|
+
o.secrets ||
|
|
102
|
+
createSecretSource({
|
|
103
|
+
env,
|
|
104
|
+
fetchSecretString: (id) => awsClients().fetchSecretString(id),
|
|
105
|
+
}),
|
|
106
|
+
store,
|
|
107
|
+
publicUrl: resolveBrokerPublicUrl(env),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createHandler(overrides) {
|
|
112
|
+
let deps = null
|
|
113
|
+
return async (event) => {
|
|
114
|
+
if (!deps) deps = resolveDeps(overrides)
|
|
115
|
+
try {
|
|
116
|
+
if (event && event.requestContext && event.requestContext.http) {
|
|
117
|
+
return await handleLambdaUrl(event, deps)
|
|
118
|
+
}
|
|
119
|
+
if (event && event.directive && event.directive.header) {
|
|
120
|
+
return await handleDirective(event, deps)
|
|
121
|
+
}
|
|
122
|
+
return jsonResponse(400, { error: 'unknown_event' })
|
|
123
|
+
} catch (err) {
|
|
124
|
+
deps.log.error('camstack-alexa: unhandled error', describe(err))
|
|
125
|
+
if (event && event.directive && event.directive.header) {
|
|
126
|
+
return errorEnvelope(
|
|
127
|
+
event.directive.header,
|
|
128
|
+
'INTERNAL_ERROR',
|
|
129
|
+
describe(err),
|
|
130
|
+
event.directive.endpoint,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
return jsonResponse(500, { error: 'internal_error', message: describe(err) })
|
|
62
134
|
}
|
|
63
|
-
return jsonResponse(500, { error: 'internal_error', message: describe(err) })
|
|
64
135
|
}
|
|
65
136
|
}
|
|
66
137
|
|
|
67
138
|
// ── Lambda Function URL (HTTP) routes ────────────────────────────────
|
|
68
139
|
|
|
69
|
-
async function handleLambdaUrl(event) {
|
|
140
|
+
async function handleLambdaUrl(event, deps) {
|
|
70
141
|
const path = event.rawPath || '/'
|
|
71
142
|
if (path === '/authorize') return handleAuthorizePage(event)
|
|
72
|
-
if (path === '/token') return handleTokenProxy(event)
|
|
143
|
+
if (path === '/token') return handleTokenProxy(event, deps)
|
|
144
|
+
if (isBrokerPath(path)) return handleBrokerRoute(event, deps)
|
|
73
145
|
return { statusCode: 404, headers: { 'content-type': 'text/plain' }, body: 'Not found' }
|
|
74
146
|
}
|
|
75
147
|
|
|
@@ -99,25 +171,36 @@ function handleAuthorizePage(event) {
|
|
|
99
171
|
button { margin-top: 1rem; width: 100%; padding: 0.7rem; font-size: 0.95rem; font-weight: 500; background: #2563eb; color: white; border: none; border-radius: 0.375rem; cursor: pointer; }
|
|
100
172
|
button:hover { background: #1d4ed8; }
|
|
101
173
|
.hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
|
|
174
|
+
.err { font-size: 0.85rem; color: #b91c1c; margin: 0.75rem 0 0; }
|
|
102
175
|
</style>
|
|
103
176
|
</head>
|
|
104
177
|
<body>
|
|
105
178
|
<div class="card">
|
|
106
179
|
<h1>Link CamStack to Alexa</h1>
|
|
107
180
|
<p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
|
|
108
|
-
<form id="f">
|
|
181
|
+
<form id="f" novalidate>
|
|
109
182
|
<label for="hubUrl">Hub URL</label>
|
|
110
183
|
<input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
|
|
111
184
|
<p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
|
|
185
|
+
<p id="err" class="err" role="alert" hidden></p>
|
|
112
186
|
<button type="submit">Continue</button>
|
|
113
187
|
</form>
|
|
114
188
|
</div>
|
|
115
189
|
<script>
|
|
116
190
|
document.getElementById('f').addEventListener('submit', function (ev) {
|
|
117
191
|
ev.preventDefault()
|
|
118
|
-
var
|
|
119
|
-
|
|
120
|
-
var
|
|
192
|
+
var err = document.getElementById('err')
|
|
193
|
+
var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/+$/, '')
|
|
194
|
+
var parsed = null
|
|
195
|
+
try { parsed = new URL(hubUrl) } catch (e) { parsed = null }
|
|
196
|
+
if (!parsed || parsed.protocol !== 'https:' || !parsed.hostname) {
|
|
197
|
+
err.textContent = 'Enter the full address of your hub, starting with https:// (for example https://camstack.example.com).'
|
|
198
|
+
err.hidden = false
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
err.hidden = true
|
|
202
|
+
hubUrl = parsed.origin + parsed.pathname.replace(/\\/+$/, '')
|
|
203
|
+
var qs = ${jsonForScript(qs)}
|
|
121
204
|
var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
|
|
122
205
|
window.location.href = url
|
|
123
206
|
})
|
|
@@ -131,27 +214,47 @@ function handleAuthorizePage(event) {
|
|
|
131
214
|
}
|
|
132
215
|
}
|
|
133
216
|
|
|
217
|
+
/** JSON for an inline <script>: `<` escaped so the value cannot close the tag. */
|
|
218
|
+
function jsonForScript(value) {
|
|
219
|
+
return JSON.stringify(value).replace(/</g, '\\u003c')
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Constant-time string equality that does not leak length via timing. */
|
|
223
|
+
function safeEqual(a, b) {
|
|
224
|
+
const ab = Buffer.from(String(a), 'utf8')
|
|
225
|
+
const bb = Buffer.from(String(b), 'utf8')
|
|
226
|
+
if (ab.length !== bb.length) {
|
|
227
|
+
crypto.timingSafeEqual(ab, ab)
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
return crypto.timingSafeEqual(ab, bb)
|
|
231
|
+
}
|
|
232
|
+
|
|
134
233
|
/**
|
|
135
|
-
* Proxy the OAuth token exchange. Alexa POSTs form-urlencoded body
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* to find the hub URL claim, then forward the
|
|
139
|
-
*
|
|
234
|
+
* Proxy the OAuth token exchange. Alexa POSTs a form-urlencoded body with the
|
|
235
|
+
* account-linking client_id/client_secret; we verify those (current secret, or
|
|
236
|
+
* `previousClientSecret` mid-rotation), decode the code/refresh_token JWT
|
|
237
|
+
* payload to find the hub URL claim, then forward the body to the hub's own
|
|
238
|
+
* /api/oauth2/token endpoint.
|
|
140
239
|
*/
|
|
141
|
-
async function handleTokenProxy(event) {
|
|
240
|
+
async function handleTokenProxy(event, deps) {
|
|
142
241
|
if (event.requestContext.http.method !== 'POST') {
|
|
143
242
|
return jsonResponse(405, { error: 'method_not_allowed' })
|
|
144
243
|
}
|
|
145
244
|
const body = parseFormUrlEncoded(decodeBody(event))
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
if (!skillId || !skillSecret) {
|
|
245
|
+
const skill = await deps.secrets.skill()
|
|
246
|
+
if (!skill) {
|
|
149
247
|
return jsonResponse(500, {
|
|
150
248
|
error: 'unconfigured',
|
|
151
|
-
message: '
|
|
249
|
+
message: 'account-linking client credentials are not configured',
|
|
152
250
|
})
|
|
153
251
|
}
|
|
154
|
-
|
|
252
|
+
const secretOk =
|
|
253
|
+
typeof body.client_secret === 'string' &&
|
|
254
|
+
(safeEqual(body.client_secret, skill.clientSecret) ||
|
|
255
|
+
(skill.previousClientSecret !== undefined &&
|
|
256
|
+
safeEqual(body.client_secret, skill.previousClientSecret)))
|
|
257
|
+
if (body.client_id !== skill.clientId || !secretOk) {
|
|
155
258
|
return jsonResponse(401, { error: 'invalid_client', message: 'client credentials mismatch' })
|
|
156
259
|
}
|
|
157
260
|
|
|
@@ -171,7 +274,7 @@ async function handleTokenProxy(event) {
|
|
|
171
274
|
}
|
|
172
275
|
|
|
173
276
|
const target = `${normaliseHubUrl(claims.hubUrl)}/api/oauth2/token`
|
|
174
|
-
const upstream = await
|
|
277
|
+
const upstream = await deps.fetch(target, {
|
|
175
278
|
method: 'POST',
|
|
176
279
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
177
280
|
body: encodeFormUrlEncoded(body),
|
|
@@ -186,141 +289,210 @@ async function handleTokenProxy(event) {
|
|
|
186
289
|
|
|
187
290
|
// ── Smart Home directives ────────────────────────────────────────────
|
|
188
291
|
|
|
189
|
-
async function handleDirective(event) {
|
|
292
|
+
async function handleDirective(event, deps) {
|
|
190
293
|
const header = event.directive.header
|
|
191
294
|
const ns = `${header.namespace}/${header.name}`
|
|
192
295
|
if (ns === 'Alexa.Authorization/AcceptGrant') {
|
|
193
|
-
return handleAcceptGrant(event)
|
|
296
|
+
return handleAcceptGrant(event, deps)
|
|
194
297
|
}
|
|
195
|
-
return forwardDirective(event)
|
|
298
|
+
return forwardDirective(event, deps)
|
|
196
299
|
}
|
|
197
300
|
|
|
198
|
-
|
|
301
|
+
function hubHeaders(deps, bearer) {
|
|
302
|
+
return {
|
|
303
|
+
'content-type': 'application/json',
|
|
304
|
+
authorization: `Bearer ${bearer}`,
|
|
305
|
+
// Defense-in-depth shared secret — the export-alexa "Ingress token"
|
|
306
|
+
// setting. Sent only when configured; the hub enforces it when the
|
|
307
|
+
// operator turns on "Require ingress token".
|
|
308
|
+
...(deps.env.HUB_INGRESS_TOKEN ? { 'x-camstack-ingress': deps.env.HUB_INGRESS_TOKEN } : {}),
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function brokerAvailable(deps) {
|
|
313
|
+
return deps.store !== null && typeof deps.publicUrl === 'string'
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function acceptGrantResponse(header) {
|
|
317
|
+
return {
|
|
318
|
+
event: {
|
|
319
|
+
header: {
|
|
320
|
+
namespace: 'Alexa.Authorization',
|
|
321
|
+
name: 'AcceptGrant.Response',
|
|
322
|
+
messageId: header.messageId,
|
|
323
|
+
payloadVersion: '3',
|
|
324
|
+
},
|
|
325
|
+
payload: {},
|
|
326
|
+
},
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function handleAcceptGrant(event, deps) {
|
|
199
331
|
const header = event.directive.header
|
|
200
332
|
const payload = event.directive.payload || {}
|
|
201
333
|
const grantCode = payload.grant && payload.grant.code
|
|
202
334
|
const granteeToken = payload.grantee && payload.grantee.token
|
|
203
335
|
if (!grantCode || !granteeToken) {
|
|
204
|
-
return
|
|
205
|
-
header,
|
|
206
|
-
'ACCEPT_GRANT_FAILED',
|
|
207
|
-
'payload missing grant.code or grantee.token',
|
|
208
|
-
)
|
|
336
|
+
return acceptGrantErrorEnvelope(header, 'payload missing grant.code or grantee.token')
|
|
209
337
|
}
|
|
210
338
|
const claims = decodeJwtPayload(granteeToken)
|
|
211
339
|
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
212
|
-
return
|
|
340
|
+
return acceptGrantErrorEnvelope(header, 'grantee token has no hubUrl claim')
|
|
341
|
+
}
|
|
342
|
+
const redirectUri = deps.env.LAMBDA_REDIRECT_URI
|
|
343
|
+
if (!redirectUri) {
|
|
344
|
+
return acceptGrantErrorEnvelope(header, 'LAMBDA_REDIRECT_URI not set on the Lambda')
|
|
213
345
|
}
|
|
214
346
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
347
|
+
let tokens
|
|
348
|
+
try {
|
|
349
|
+
tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
return acceptGrantErrorEnvelope(header, describe(err))
|
|
352
|
+
}
|
|
353
|
+
const hubUrl = normaliseHubUrl(claims.hubUrl)
|
|
354
|
+
|
|
355
|
+
if (brokerAvailable(deps)) {
|
|
356
|
+
const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
|
|
357
|
+
if (outcome.kind === 'linked') return acceptGrantResponse(header)
|
|
358
|
+
if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
|
|
359
|
+
return acceptGrantErrorEnvelope(header, outcome.message)
|
|
360
|
+
}
|
|
361
|
+
deps.log.warn(
|
|
362
|
+
'accept_grant: hub has no broker route; falling back to the legacy token hand-off',
|
|
223
363
|
)
|
|
224
364
|
}
|
|
365
|
+
return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
|
|
366
|
+
}
|
|
225
367
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}),
|
|
368
|
+
/**
|
|
369
|
+
* The D753 link: store the grant here, give the hub only a credential and a
|
|
370
|
+
* first access token. The item stays `pending` until the hub accepted it, so a
|
|
371
|
+
* hub that refuses (or does not have the route) leaves nothing behind.
|
|
372
|
+
*/
|
|
373
|
+
async function linkViaBroker(deps, input) {
|
|
374
|
+
const { item, credential } = await createLink(deps, {
|
|
375
|
+
status: 'pending',
|
|
376
|
+
tokens: input.tokens,
|
|
377
|
+
hubUrl: input.hubUrl,
|
|
378
|
+
origin: 'accept-grant',
|
|
238
379
|
})
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
380
|
+
let resp
|
|
381
|
+
try {
|
|
382
|
+
resp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/broker-link`, {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: hubHeaders(deps, input.granteeToken),
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
brokerUrl: deps.publicUrl,
|
|
387
|
+
brokerCredential: credential,
|
|
388
|
+
lwaAccessToken: item.accessToken,
|
|
389
|
+
expiresAt: item.accessExpiresAt,
|
|
390
|
+
}),
|
|
391
|
+
})
|
|
392
|
+
} catch (err) {
|
|
393
|
+
await deps.store.remove(item.linkId)
|
|
394
|
+
return { kind: 'failed', message: `hub broker-link unreachable: ${describe(err)}` }
|
|
395
|
+
}
|
|
396
|
+
if (resp.ok) {
|
|
397
|
+
await activateLink(deps, item)
|
|
398
|
+
deps.log.info('accept_grant: linked via broker', { link: shortId(item.linkId) })
|
|
399
|
+
return { kind: 'linked' }
|
|
242
400
|
}
|
|
243
|
-
|
|
244
|
-
|
|
401
|
+
await deps.store.remove(item.linkId)
|
|
402
|
+
if (resp.status === 404) return { kind: 'hub-without-broker', message: 'hub broker-link 404' }
|
|
403
|
+
const text = await resp.text().catch(() => '')
|
|
404
|
+
return { kind: 'failed', message: `hub broker-link ${resp.status}: ${text.slice(0, 200)}` }
|
|
405
|
+
}
|
|
245
406
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const hubUrl = normaliseHubUrl(claims.hubUrl)
|
|
250
|
-
const callback = `${hubUrl}/addon/export-alexa/oauth/lwa-tokens`
|
|
251
|
-
const callbackResp = await timedFetch(callback, {
|
|
407
|
+
/** Pre-D753 hand-off: the hub receives the refresh token itself. */
|
|
408
|
+
async function deliverTokensToLegacyHub(deps, header, input) {
|
|
409
|
+
const callbackResp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/lwa-tokens`, {
|
|
252
410
|
method: 'POST',
|
|
253
|
-
headers:
|
|
254
|
-
'content-type': 'application/json',
|
|
255
|
-
authorization: `Bearer ${granteeToken}`,
|
|
256
|
-
...(process.env.HUB_INGRESS_TOKEN
|
|
257
|
-
? { 'x-camstack-ingress': process.env.HUB_INGRESS_TOKEN }
|
|
258
|
-
: {}),
|
|
259
|
-
},
|
|
411
|
+
headers: hubHeaders(deps, input.granteeToken),
|
|
260
412
|
body: JSON.stringify({
|
|
261
|
-
lwaAccessToken:
|
|
262
|
-
lwaRefreshToken:
|
|
263
|
-
expiresAt:
|
|
413
|
+
lwaAccessToken: input.tokens.accessToken,
|
|
414
|
+
lwaRefreshToken: input.tokens.refreshToken,
|
|
415
|
+
expiresAt: deps.now() + input.tokens.expiresInSec * 1000,
|
|
264
416
|
}),
|
|
265
417
|
})
|
|
266
418
|
if (!callbackResp.ok) {
|
|
267
419
|
const text = await callbackResp.text().catch(() => '')
|
|
268
|
-
return
|
|
420
|
+
return acceptGrantErrorEnvelope(
|
|
269
421
|
header,
|
|
270
|
-
|
|
271
|
-
`hub callback ${callbackResp.status}: ${text}`,
|
|
422
|
+
`hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
|
|
272
423
|
)
|
|
273
424
|
}
|
|
425
|
+
return acceptGrantResponse(header)
|
|
426
|
+
}
|
|
274
427
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
namespace: 'Alexa.Authorization',
|
|
279
|
-
name: 'AcceptGrant.Response',
|
|
280
|
-
messageId: header.messageId,
|
|
281
|
-
payloadVersion: '3',
|
|
282
|
-
},
|
|
283
|
-
payload: {},
|
|
284
|
-
},
|
|
285
|
-
}
|
|
428
|
+
/** HTTP statuses that mean the hub itself is down or unreachable behind its tunnel. */
|
|
429
|
+
function isBridgeDownStatus(status) {
|
|
430
|
+
return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530)
|
|
286
431
|
}
|
|
287
432
|
|
|
288
|
-
async function forwardDirective(event) {
|
|
289
|
-
const
|
|
290
|
-
const
|
|
433
|
+
async function forwardDirective(event, deps) {
|
|
434
|
+
const directive = event.directive
|
|
435
|
+
const header = directive.header
|
|
436
|
+
const endpoint = directive.endpoint
|
|
437
|
+
const bearer = extractBearer(directive)
|
|
291
438
|
if (!bearer) {
|
|
292
439
|
return errorEnvelope(
|
|
293
440
|
header,
|
|
294
441
|
'INVALID_AUTHORIZATION_CREDENTIAL',
|
|
295
442
|
'directive has no bearer token',
|
|
443
|
+
endpoint,
|
|
296
444
|
)
|
|
297
445
|
}
|
|
298
446
|
const claims = decodeJwtPayload(bearer)
|
|
299
447
|
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
300
|
-
return errorEnvelope(
|
|
448
|
+
return errorEnvelope(
|
|
449
|
+
header,
|
|
450
|
+
'INVALID_AUTHORIZATION_CREDENTIAL',
|
|
451
|
+
'bearer has no hubUrl claim',
|
|
452
|
+
endpoint,
|
|
453
|
+
)
|
|
301
454
|
}
|
|
302
455
|
const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
456
|
+
let resp
|
|
457
|
+
try {
|
|
458
|
+
resp = await deps.fetch(target, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: {
|
|
461
|
+
...hubHeaders(deps, bearer),
|
|
462
|
+
// Where this skill's broker lives. A hub linked before D753 uses it to
|
|
463
|
+
// migrate its refresh token here (zero-touch); the hub only believes it
|
|
464
|
+
// on a directive whose bearer it verified.
|
|
465
|
+
...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
|
|
466
|
+
},
|
|
467
|
+
body: JSON.stringify(event),
|
|
468
|
+
})
|
|
469
|
+
} catch (err) {
|
|
470
|
+
// The hub is the bridge every endpoint sits behind (alexa-errorresponse).
|
|
471
|
+
return errorEnvelope(
|
|
472
|
+
header,
|
|
473
|
+
'BRIDGE_UNREACHABLE',
|
|
474
|
+
`hub unreachable: ${describe(err)}`,
|
|
475
|
+
endpoint,
|
|
476
|
+
)
|
|
312
477
|
}
|
|
313
|
-
const resp = await timedFetch(target, {
|
|
314
|
-
method: 'POST',
|
|
315
|
-
headers: directiveHeaders,
|
|
316
|
-
body: JSON.stringify(event),
|
|
317
|
-
})
|
|
318
478
|
const text = await resp.text()
|
|
479
|
+
let parsed
|
|
319
480
|
try {
|
|
320
|
-
|
|
481
|
+
parsed = JSON.parse(text)
|
|
321
482
|
} catch {
|
|
322
|
-
|
|
483
|
+
const type = isBridgeDownStatus(resp.status) ? 'BRIDGE_UNREACHABLE' : 'INTERNAL_ERROR'
|
|
484
|
+
return errorEnvelope(header, type, `hub returned ${resp.status}, non-JSON`, endpoint)
|
|
323
485
|
}
|
|
486
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.event || !parsed.event.header) {
|
|
487
|
+
// e.g. `{ error: 'invalid_ingress_token' }` — never hand Alexa a non-event.
|
|
488
|
+
return errorEnvelope(
|
|
489
|
+
header,
|
|
490
|
+
'INTERNAL_ERROR',
|
|
491
|
+
`hub returned ${resp.status} without an Alexa event`,
|
|
492
|
+
endpoint,
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
return completeErrorResponse(parsed, directive)
|
|
324
496
|
}
|
|
325
497
|
|
|
326
498
|
function extractBearer(directive) {
|
|
@@ -341,227 +513,6 @@ function extractBearer(directive) {
|
|
|
341
513
|
return null
|
|
342
514
|
}
|
|
343
515
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (typeof token !== 'string') return null
|
|
348
|
-
const parts = token.split('.')
|
|
349
|
-
if (parts.length < 2) return null
|
|
350
|
-
try {
|
|
351
|
-
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
|
352
|
-
const json = Buffer.from(padded, 'base64').toString('utf8')
|
|
353
|
-
return JSON.parse(json)
|
|
354
|
-
} catch {
|
|
355
|
-
return null
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function normaliseHubUrl(raw) {
|
|
360
|
-
return raw.replace(/\/+$/, '')
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function decodeBody(event) {
|
|
364
|
-
if (!event.body) return ''
|
|
365
|
-
if (event.isBase64Encoded) {
|
|
366
|
-
return Buffer.from(event.body, 'base64').toString('utf8')
|
|
367
|
-
}
|
|
368
|
-
return event.body
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function parseFormUrlEncoded(body) {
|
|
372
|
-
const out = {}
|
|
373
|
-
if (!body) return out
|
|
374
|
-
for (const pair of body.split('&')) {
|
|
375
|
-
if (!pair) continue
|
|
376
|
-
const eq = pair.indexOf('=')
|
|
377
|
-
const key = decodeURIComponent(eq >= 0 ? pair.slice(0, eq) : pair).replace(/\+/g, ' ')
|
|
378
|
-
const value = decodeURIComponent(eq >= 0 ? pair.slice(eq + 1) : '').replace(/\+/g, ' ')
|
|
379
|
-
out[key] = value
|
|
380
|
-
}
|
|
381
|
-
return out
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function encodeFormUrlEncoded(obj) {
|
|
385
|
-
return Object.keys(obj)
|
|
386
|
-
.filter((k) => obj[k] !== undefined && obj[k] !== null)
|
|
387
|
-
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(obj[k]))}`)
|
|
388
|
-
.join('&')
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
async function timedFetch(url, init = {}) {
|
|
392
|
-
// Returns a fetch-compatible response object: { ok, status, statusText,
|
|
393
|
-
// text(), json() }. The handler only uses .text() / status.
|
|
394
|
-
//
|
|
395
|
-
// Emits a single structured timing log line on completion (or failure),
|
|
396
|
-
// measuring DNS / TCP / TLS / request-sent / first-byte / last-byte sub-
|
|
397
|
-
// timings. Read these from CloudWatch with:
|
|
398
|
-
// filter @message like /timedFetch_timing/
|
|
399
|
-
// to see end-to-end where the wall-clock seconds go (DNS hang vs TCP RTT
|
|
400
|
-
// vs TLS handshake vs hub processing time vs body download).
|
|
401
|
-
return new Promise((resolve, reject) => {
|
|
402
|
-
let settled = false
|
|
403
|
-
const parsed = new URL(url)
|
|
404
|
-
const isHttps = parsed.protocol === 'https:'
|
|
405
|
-
const lib = isHttps ? https : http
|
|
406
|
-
const headers = { ...(init.headers || {}), connection: 'close' }
|
|
407
|
-
if (init.body !== undefined && headers['content-length'] === undefined) {
|
|
408
|
-
headers['content-length'] = Buffer.byteLength(init.body)
|
|
409
|
-
}
|
|
410
|
-
const T0 = process.hrtime.bigint()
|
|
411
|
-
const marks = {
|
|
412
|
-
dns: null,
|
|
413
|
-
tcpConnect: null,
|
|
414
|
-
tlsHandshake: null,
|
|
415
|
-
requestSent: null,
|
|
416
|
-
firstByte: null,
|
|
417
|
-
lastByte: null,
|
|
418
|
-
}
|
|
419
|
-
const ms = (a, b) => Number((b - a) / 1_000_000n)
|
|
420
|
-
const logTiming = (status, extras = {}) => {
|
|
421
|
-
const T_end = process.hrtime.bigint()
|
|
422
|
-
const total = ms(T0, T_end)
|
|
423
|
-
const stages = {
|
|
424
|
-
dns: marks.dns ? ms(T0, marks.dns) : null,
|
|
425
|
-
tcp: marks.tcpConnect ? ms(T0, marks.tcpConnect) : null,
|
|
426
|
-
tls: marks.tlsHandshake ? ms(T0, marks.tlsHandshake) : null,
|
|
427
|
-
reqSent: marks.requestSent ? ms(T0, marks.requestSent) : null,
|
|
428
|
-
firstByte: marks.firstByte ? ms(T0, marks.firstByte) : null,
|
|
429
|
-
lastByte: marks.lastByte ? ms(T0, marks.lastByte) : null,
|
|
430
|
-
}
|
|
431
|
-
// Stage durations between marks, easier to read at a glance:
|
|
432
|
-
const between = {
|
|
433
|
-
dns_ms: stages.dns,
|
|
434
|
-
tcp_handshake_ms:
|
|
435
|
-
stages.tcp != null && stages.dns != null ? stages.tcp - stages.dns : stages.tcp,
|
|
436
|
-
tls_handshake_ms: stages.tls != null && stages.tcp != null ? stages.tls - stages.tcp : null,
|
|
437
|
-
request_send_ms:
|
|
438
|
-
stages.reqSent != null && (stages.tls ?? stages.tcp) != null
|
|
439
|
-
? stages.reqSent - (stages.tls ?? stages.tcp)
|
|
440
|
-
: null,
|
|
441
|
-
wait_first_byte_ms:
|
|
442
|
-
stages.firstByte != null && stages.reqSent != null
|
|
443
|
-
? stages.firstByte - stages.reqSent
|
|
444
|
-
: null,
|
|
445
|
-
download_ms:
|
|
446
|
-
stages.lastByte != null && stages.firstByte != null
|
|
447
|
-
? stages.lastByte - stages.firstByte
|
|
448
|
-
: null,
|
|
449
|
-
}
|
|
450
|
-
console.log(
|
|
451
|
-
'timedFetch_timing',
|
|
452
|
-
JSON.stringify({
|
|
453
|
-
url: `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`,
|
|
454
|
-
method: (init.method || 'GET').toUpperCase(),
|
|
455
|
-
status,
|
|
456
|
-
total_ms: total,
|
|
457
|
-
stages_cum_ms: stages,
|
|
458
|
-
between_ms: between,
|
|
459
|
-
...extras,
|
|
460
|
-
}),
|
|
461
|
-
)
|
|
462
|
-
}
|
|
463
|
-
const req = lib.request(
|
|
464
|
-
{
|
|
465
|
-
protocol: parsed.protocol,
|
|
466
|
-
hostname: parsed.hostname,
|
|
467
|
-
port: parsed.port || (isHttps ? 443 : 80),
|
|
468
|
-
path: `${parsed.pathname}${parsed.search}`,
|
|
469
|
-
method: (init.method || 'GET').toUpperCase(),
|
|
470
|
-
headers,
|
|
471
|
-
agent: isHttps ? NO_KEEPALIVE_AGENT : undefined,
|
|
472
|
-
},
|
|
473
|
-
(res) => {
|
|
474
|
-
marks.firstByte = process.hrtime.bigint()
|
|
475
|
-
const chunks = []
|
|
476
|
-
res.on('data', (c) => chunks.push(c))
|
|
477
|
-
res.on('end', () => {
|
|
478
|
-
if (settled) return
|
|
479
|
-
settled = true
|
|
480
|
-
clearTimeout(timer)
|
|
481
|
-
marks.lastByte = process.hrtime.bigint()
|
|
482
|
-
const body = Buffer.concat(chunks).toString('utf8')
|
|
483
|
-
const headersApi = {
|
|
484
|
-
get: (name) => {
|
|
485
|
-
const v = res.headers[String(name).toLowerCase()]
|
|
486
|
-
if (Array.isArray(v)) return v.join(', ')
|
|
487
|
-
return v == null ? null : String(v)
|
|
488
|
-
},
|
|
489
|
-
}
|
|
490
|
-
logTiming(res.statusCode, { responseBodyBytes: body.length })
|
|
491
|
-
resolve({
|
|
492
|
-
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
493
|
-
status: res.statusCode,
|
|
494
|
-
statusText: res.statusMessage || '',
|
|
495
|
-
headers: headersApi,
|
|
496
|
-
text: async () => body,
|
|
497
|
-
json: async () => JSON.parse(body),
|
|
498
|
-
})
|
|
499
|
-
})
|
|
500
|
-
res.on('error', (err) => {
|
|
501
|
-
if (settled) return
|
|
502
|
-
settled = true
|
|
503
|
-
clearTimeout(timer)
|
|
504
|
-
logTiming('res_error', { error: err && err.message })
|
|
505
|
-
reject(err)
|
|
506
|
-
})
|
|
507
|
-
},
|
|
508
|
-
)
|
|
509
|
-
req.on('socket', (socket) => {
|
|
510
|
-
socket.on('lookup', () => {
|
|
511
|
-
marks.dns = process.hrtime.bigint()
|
|
512
|
-
})
|
|
513
|
-
socket.on('connect', () => {
|
|
514
|
-
marks.tcpConnect = process.hrtime.bigint()
|
|
515
|
-
})
|
|
516
|
-
socket.on('secureConnect', () => {
|
|
517
|
-
marks.tlsHandshake = process.hrtime.bigint()
|
|
518
|
-
})
|
|
519
|
-
})
|
|
520
|
-
req.on('finish', () => {
|
|
521
|
-
marks.requestSent = process.hrtime.bigint()
|
|
522
|
-
})
|
|
523
|
-
req.on('error', (err) => {
|
|
524
|
-
if (settled) return
|
|
525
|
-
settled = true
|
|
526
|
-
clearTimeout(timer)
|
|
527
|
-
logTiming('req_error', { error: err && err.message, code: err && err.code })
|
|
528
|
-
reject(err)
|
|
529
|
-
})
|
|
530
|
-
const timer = setTimeout(() => {
|
|
531
|
-
if (settled) return
|
|
532
|
-
settled = true
|
|
533
|
-
req.destroy(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
|
|
534
|
-
logTiming('timeout', { timeout_ms: FETCH_TIMEOUT_MS })
|
|
535
|
-
reject(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
|
|
536
|
-
}, FETCH_TIMEOUT_MS)
|
|
537
|
-
if (init.body !== undefined) req.write(init.body)
|
|
538
|
-
req.end()
|
|
539
|
-
})
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
function jsonResponse(statusCode, body) {
|
|
543
|
-
return {
|
|
544
|
-
statusCode,
|
|
545
|
-
headers: { 'content-type': 'application/json' },
|
|
546
|
-
body: JSON.stringify(body),
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
function errorEnvelope(header, type, message) {
|
|
551
|
-
return {
|
|
552
|
-
event: {
|
|
553
|
-
header: {
|
|
554
|
-
namespace: 'Alexa',
|
|
555
|
-
name: 'ErrorResponse',
|
|
556
|
-
messageId: header.messageId,
|
|
557
|
-
payloadVersion: '3',
|
|
558
|
-
},
|
|
559
|
-
payload: { type, message },
|
|
560
|
-
},
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
function describe(err) {
|
|
565
|
-
if (err instanceof Error) return err.message
|
|
566
|
-
return String(err)
|
|
567
|
-
}
|
|
516
|
+
exports.handler = createHandler()
|
|
517
|
+
exports.createHandler = createHandler
|
|
518
|
+
exports.BROKER_HEADER = BROKER_HEADER
|