@camstack/addon-export-alexa 1.2.130 → 1.2.132
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 +846 -97
- package/dist/export-alexa.addon.mjs +846 -97
- package/lambda-template/README.md +116 -0
- package/lambda-template/icons/alexa-skill-icon-108.png +0 -0
- package/lambda-template/icons/alexa-skill-icon-512.png +0 -0
- package/lambda-template/index.js +567 -0
- package/package.json +3 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# CamStack Alexa Lambda (Option 2 — single hosted skill)
|
|
2
|
+
|
|
3
|
+
This Lambda is paired with **one** Alexa Smart Home skill published on
|
|
4
|
+
[developer.amazon.com](https://developer.amazon.com) under the camstack
|
|
5
|
+
brand. Every camstack user reaches the same Lambda; the Lambda decodes
|
|
6
|
+
each user's bearer JWT to learn which hub URL to forward to, so it
|
|
7
|
+
remains completely stateless.
|
|
8
|
+
|
|
9
|
+
## What it does
|
|
10
|
+
|
|
11
|
+
| Invocation | Action |
|
|
12
|
+
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
13
|
+
| `GET <FUNCTION_URL>/authorize` | Renders an HTML form asking the user for their hub URL, then redirects to the hub's OAuth `/authorize`. |
|
|
14
|
+
| `POST <FUNCTION_URL>/token` | Decodes the inbound JWT, looks up the `hubUrl` claim, proxies the form-encoded token request to the hub. |
|
|
15
|
+
| Smart Home directive (`event.directive`) | Decodes the bearer, forwards JSON to `${hubUrl}/addon/export-alexa/directive`. |
|
|
16
|
+
| `Alexa.Authorization/AcceptGrant` | Exchanges the grant code at `api.amazon.com`, then POSTs LWA tokens back to the hub. |
|
|
17
|
+
|
|
18
|
+
## Deployment
|
|
19
|
+
|
|
20
|
+
The Lambda is a single 10 kB file with **no node_modules**. The whole
|
|
21
|
+
build is just `zip index.zip index.js`. It ships in the published
|
|
22
|
+
`@camstack/addon-export-alexa` npm package (`npm pack` it and take
|
|
23
|
+
`package/lambda-template/`), which is how the public docs page "Your own Alexa
|
|
24
|
+
skill" tells operators to get it.
|
|
25
|
+
|
|
26
|
+
Deploy it in the AWS region that serves your skill's locale: NA →
|
|
27
|
+
`us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-west-2`.
|
|
28
|
+
|
|
29
|
+
1. Create a new Lambda function in your AWS account:
|
|
30
|
+
- **Runtime:** Node.js 22.x or newer (the file uses only Node built-ins)
|
|
31
|
+
- **Architecture:** arm64 (or x86_64 — irrelevant for a small JS Lambda)
|
|
32
|
+
- **Timeout:** 8 seconds (Alexa's hard limit)
|
|
33
|
+
- **Memory:** 256 MB is plenty
|
|
34
|
+
|
|
35
|
+
2. Upload `index.js` (zip first or paste into the inline editor).
|
|
36
|
+
|
|
37
|
+
3. Add a **Function URL** trigger:
|
|
38
|
+
- **Auth type:** NONE
|
|
39
|
+
- Note the assigned URL — it looks like
|
|
40
|
+
`https://abc123xyz.lambda-url.eu-west-1.on.aws/`.
|
|
41
|
+
|
|
42
|
+
4. Configure environment variables (see below).
|
|
43
|
+
|
|
44
|
+
5. Add an **Alexa** → **Alexa Smart Home** trigger and paste the skill id
|
|
45
|
+
(`amzn1.ask.skill.…`). Create the skill first to get it.
|
|
46
|
+
|
|
47
|
+
6. Note the Lambda **ARN** (Function overview → Function ARN).
|
|
48
|
+
|
|
49
|
+
## Environment variables
|
|
50
|
+
|
|
51
|
+
| Name | Value |
|
|
52
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
53
|
+
| `SKILL_CLIENT_ID` | Any opaque string. Paste the same value into the Alexa skill manifest's **Client ID** field on Account Linking. |
|
|
54
|
+
| `SKILL_CLIENT_SECRET` | Any opaque string. Paste the same value into the Alexa skill manifest's **Client Secret** field. |
|
|
55
|
+
| `LWA_CLIENT_ID` | From the skill's **Permissions → Send Alexa Events** panel. Used for the AcceptGrant LWA token exchange. |
|
|
56
|
+
| `LWA_CLIENT_SECRET` | Paired LWA client secret. |
|
|
57
|
+
| `LAMBDA_REDIRECT_URI` | The Function URL + `/token`, e.g. `https://abc123xyz.lambda-url.eu-west-1.on.aws/token`. |
|
|
58
|
+
| `HUB_INGRESS_TOKEN` | Optional defense-in-depth shared secret. Copy it from the CamStack Alexa addon's **Ingress token (Lambda)** setting. Sent as the `X-CamStack-Ingress` header on hub calls (`/directive`, `/oauth/lwa-tokens`); the hub enforces it once you enable **Require ingress token**. Leave unset to skip. |
|
|
59
|
+
Generate the two skill secrets with anything strong, e.g.
|
|
60
|
+
`openssl rand -hex 32`. The Lambda only compares them — no decoding.
|
|
61
|
+
|
|
62
|
+
## Skill manifest fields
|
|
63
|
+
|
|
64
|
+
In the [Alexa developer console](https://developer.amazon.com/alexa/console/ask)
|
|
65
|
+
for your **Smart Home** skill:
|
|
66
|
+
|
|
67
|
+
### 1. Endpoint
|
|
68
|
+
|
|
69
|
+
| Field | Value |
|
|
70
|
+
| ----------------- | ------------------------------------------------------------------ |
|
|
71
|
+
| Default endpoint | `<LAMBDA_ARN>` (Function overview → Function ARN) |
|
|
72
|
+
|
|
73
|
+
### 2. Account Linking
|
|
74
|
+
|
|
75
|
+
| Field | Value |
|
|
76
|
+
| -------------------------------------- | ---------------------------------------------------------------------------- |
|
|
77
|
+
| Authorization Grant Type | Auth Code Grant |
|
|
78
|
+
| Authorization URI | `<FUNCTION_URL>/authorize` |
|
|
79
|
+
| Access Token URI | `<FUNCTION_URL>/token` |
|
|
80
|
+
| Client ID | `SKILL_CLIENT_ID` env var (same string) |
|
|
81
|
+
| Client Secret | `SKILL_CLIENT_SECRET` env var (same string) |
|
|
82
|
+
| Client Authentication Scheme | Credentials in request body (**not** HTTP Basic — the Lambda reads them from the body) |
|
|
83
|
+
| Scope | _(leave empty)_ |
|
|
84
|
+
| Domain List | _(leave empty — operators supply arbitrary hub URLs at runtime)_ |
|
|
85
|
+
|
|
86
|
+
### 3. Permissions (proactive events)
|
|
87
|
+
|
|
88
|
+
Enable **Send Alexa Events** and copy the generated LWA client id /
|
|
89
|
+
secret into the Lambda env (`LWA_CLIENT_ID` / `LWA_CLIENT_SECRET`).
|
|
90
|
+
|
|
91
|
+
## How the routing works
|
|
92
|
+
|
|
93
|
+
Every hub-issued JWT (auth code, access token, refresh token) carries a
|
|
94
|
+
`hubUrl` claim equal to that hub's public HTTPS origin. The Lambda
|
|
95
|
+
**base64-decodes** the payload — no signature check. The hub verifies
|
|
96
|
+
the signature when the request lands. Threat model: a forged token with
|
|
97
|
+
a malicious `hubUrl` only reaches that malicious hub; cross-hub
|
|
98
|
+
spillover is not possible because the malicious hub still has to mint a
|
|
99
|
+
token its own auth-manager signed before Alexa stores it.
|
|
100
|
+
|
|
101
|
+
> The hub no longer validates `client_id` / `client_secret`. That pair is
|
|
102
|
+
> checked only here in the Lambda. On the hub, `/api/oauth2/authorize` is a
|
|
103
|
+
> session-authenticated route — an unauthenticated browser is redirected
|
|
104
|
+
> to the normal CamStack login.
|
|
105
|
+
|
|
106
|
+
## Local testing
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
# Pretend to be Alexa's token endpoint hitting the Lambda.
|
|
110
|
+
curl -X POST "$FUNCTION_URL/token" \
|
|
111
|
+
-H 'content-type: application/x-www-form-urlencoded' \
|
|
112
|
+
-d 'grant_type=authorization_code' \
|
|
113
|
+
-d 'code=<some-jwt-with-hubUrl-claim>' \
|
|
114
|
+
-d "client_id=$SKILL_CLIENT_ID" \
|
|
115
|
+
-d "client_secret=$SKILL_CLIENT_SECRET"
|
|
116
|
+
```
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CamStack Alexa Smart Home Lambda — Option 2 (single hosted skill).
|
|
3
|
+
*
|
|
4
|
+
* One Lambda + one skill on developer.amazon.com serves ALL camstack
|
|
5
|
+
* users. The Lambda is stateless: it inspects an inbound event, decodes
|
|
6
|
+
* the bearer JWT payload to extract the operator's hub URL, then proxies
|
|
7
|
+
* the request to that hub's core `/api/oauth2/*` endpoints.
|
|
8
|
+
*
|
|
9
|
+
* Invocation shapes:
|
|
10
|
+
* 1. Lambda URL HTTP GET /authorize → render hub-URL entry form
|
|
11
|
+
* 2. Lambda URL HTTP POST /token → proxy OAuth token exchange to hub
|
|
12
|
+
* 3. Smart Home directive (event.directive.header):
|
|
13
|
+
* - Alexa.Authorization/AcceptGrant → exchange LWA code at Amazon,
|
|
14
|
+
* forward tokens to hub
|
|
15
|
+
* - everything else → forward directive to hub
|
|
16
|
+
*
|
|
17
|
+
* Required environment variables:
|
|
18
|
+
* SKILL_CLIENT_ID OAuth client id Alexa sends on /token (skill
|
|
19
|
+
* manifest "Client ID"). Free-form; we just
|
|
20
|
+
* compare it.
|
|
21
|
+
* SKILL_CLIENT_SECRET Paired client secret (skill manifest "Client
|
|
22
|
+
* Secret"). Free-form; compared against the
|
|
23
|
+
* inbound POST body.
|
|
24
|
+
* LWA_CLIENT_ID Login With Amazon client id from the skill's
|
|
25
|
+
* "Permissions → Send Alexa Events" panel. Used
|
|
26
|
+
* in the AcceptGrant LWA token exchange.
|
|
27
|
+
* LWA_CLIENT_SECRET Paired LWA client secret.
|
|
28
|
+
* LAMBDA_REDIRECT_URI The Function URL + "/token" (e.g.
|
|
29
|
+
* https://abc123.lambda-url.eu-west-1.on.aws/token).
|
|
30
|
+
* Passed as `redirect_uri` to api.amazon.com.
|
|
31
|
+
*
|
|
32
|
+
* Ships as a single file — no node_modules, no transpile. Targets the
|
|
33
|
+
* AWS Lambda Node 20.x runtime which exposes built-in `fetch`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const FETCH_TIMEOUT_MS = 6000
|
|
37
|
+
|
|
38
|
+
// Use https.request with a no-keep-alive Agent. Node's built-in fetch
|
|
39
|
+
// (undici-backed) reuses pooled sockets aggressively; in AWS Lambda the
|
|
40
|
+
// invocation-to-invocation gaps can leave pooled sockets stale, and the next
|
|
41
|
+
// fetch throws DOMException AbortError in <1s. https.request gives us
|
|
42
|
+
// explicit control. undici itself is not require-able in the Node 20 Lambda
|
|
43
|
+
// runtime, so we use the standard https module directly.
|
|
44
|
+
const https = require('node:https')
|
|
45
|
+
const http = require('node:http')
|
|
46
|
+
const { URL } = require('node:url')
|
|
47
|
+
const NO_KEEPALIVE_AGENT = new https.Agent({ keepAlive: false })
|
|
48
|
+
|
|
49
|
+
exports.handler = async (event) => {
|
|
50
|
+
try {
|
|
51
|
+
if (event && event.requestContext && event.requestContext.http) {
|
|
52
|
+
return await handleLambdaUrl(event)
|
|
53
|
+
}
|
|
54
|
+
if (event && event.directive && event.directive.header) {
|
|
55
|
+
return await handleDirective(event)
|
|
56
|
+
}
|
|
57
|
+
return jsonResponse(400, { error: 'unknown_event' })
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('camstack-alexa: unhandled error', err)
|
|
60
|
+
if (event && event.directive && event.directive.header) {
|
|
61
|
+
return errorEnvelope(event.directive.header, 'INTERNAL_ERROR', describe(err))
|
|
62
|
+
}
|
|
63
|
+
return jsonResponse(500, { error: 'internal_error', message: describe(err) })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Lambda Function URL (HTTP) routes ────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async function handleLambdaUrl(event) {
|
|
70
|
+
const path = event.rawPath || '/'
|
|
71
|
+
if (path === '/authorize') return handleAuthorizePage(event)
|
|
72
|
+
if (path === '/token') return handleTokenProxy(event)
|
|
73
|
+
return { statusCode: 404, headers: { 'content-type': 'text/plain' }, body: 'Not found' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render the "Enter your CamStack hub URL" form. Submitting the form
|
|
78
|
+
* forwards the original Alexa query params (response_type, client_id,
|
|
79
|
+
* redirect_uri, state, scope) — plus `integration=export-alexa` — to
|
|
80
|
+
* `${hubUrl}/api/oauth2/authorize`. From there the browser stays on the hub's
|
|
81
|
+
* origin until the OAuth code round-trips back to Alexa.
|
|
82
|
+
*/
|
|
83
|
+
function handleAuthorizePage(event) {
|
|
84
|
+
const qs = event.rawQueryString || ''
|
|
85
|
+
const html = `<!doctype html>
|
|
86
|
+
<html lang="en">
|
|
87
|
+
<head>
|
|
88
|
+
<meta charset="utf-8">
|
|
89
|
+
<title>CamStack · Link your hub</title>
|
|
90
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
91
|
+
<style>
|
|
92
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 480px; margin: 4rem auto; padding: 0 1rem; color: #1f2937; background: #f9fafb; }
|
|
93
|
+
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 2rem; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
94
|
+
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
|
95
|
+
p { font-size: 0.9rem; color: #4b5563; margin: 0 0 1.5rem; }
|
|
96
|
+
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.5rem; }
|
|
97
|
+
input { width: 100%; padding: 0.6rem 0.75rem; font-size: 0.95rem; border: 1px solid #d1d5db; border-radius: 0.375rem; box-sizing: border-box; font-family: ui-monospace, Menlo, monospace; }
|
|
98
|
+
input:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
|
|
99
|
+
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
|
+
button:hover { background: #1d4ed8; }
|
|
101
|
+
.hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
|
|
102
|
+
</style>
|
|
103
|
+
</head>
|
|
104
|
+
<body>
|
|
105
|
+
<div class="card">
|
|
106
|
+
<h1>Link CamStack to Alexa</h1>
|
|
107
|
+
<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">
|
|
109
|
+
<label for="hubUrl">Hub URL</label>
|
|
110
|
+
<input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
|
|
111
|
+
<p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
|
|
112
|
+
<button type="submit">Continue</button>
|
|
113
|
+
</form>
|
|
114
|
+
</div>
|
|
115
|
+
<script>
|
|
116
|
+
document.getElementById('f').addEventListener('submit', function (ev) {
|
|
117
|
+
ev.preventDefault()
|
|
118
|
+
var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/$/, '')
|
|
119
|
+
if (!hubUrl) return
|
|
120
|
+
var qs = ${JSON.stringify(qs)}
|
|
121
|
+
var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
|
|
122
|
+
window.location.href = url
|
|
123
|
+
})
|
|
124
|
+
</script>
|
|
125
|
+
</body>
|
|
126
|
+
</html>`
|
|
127
|
+
return {
|
|
128
|
+
statusCode: 200,
|
|
129
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
130
|
+
body: html,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Proxy the OAuth token exchange. Alexa POSTs form-urlencoded body to
|
|
136
|
+
* this Function URL with the client_id/client_secret from the skill
|
|
137
|
+
* manifest; we verify those, decode the code/refresh_token JWT payload
|
|
138
|
+
* to find the hub URL claim, then forward the same body to the hub's
|
|
139
|
+
* own /api/oauth2/token endpoint.
|
|
140
|
+
*/
|
|
141
|
+
async function handleTokenProxy(event) {
|
|
142
|
+
if (event.requestContext.http.method !== 'POST') {
|
|
143
|
+
return jsonResponse(405, { error: 'method_not_allowed' })
|
|
144
|
+
}
|
|
145
|
+
const body = parseFormUrlEncoded(decodeBody(event))
|
|
146
|
+
const skillId = process.env.SKILL_CLIENT_ID
|
|
147
|
+
const skillSecret = process.env.SKILL_CLIENT_SECRET
|
|
148
|
+
if (!skillId || !skillSecret) {
|
|
149
|
+
return jsonResponse(500, {
|
|
150
|
+
error: 'unconfigured',
|
|
151
|
+
message: 'SKILL_CLIENT_ID / SKILL_CLIENT_SECRET not set',
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
if (body.client_id !== skillId || body.client_secret !== skillSecret) {
|
|
155
|
+
return jsonResponse(401, { error: 'invalid_client', message: 'client credentials mismatch' })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const tokenInput = body.grant_type === 'refresh_token' ? body.refresh_token : body.code
|
|
159
|
+
if (!tokenInput) {
|
|
160
|
+
return jsonResponse(400, {
|
|
161
|
+
error: 'invalid_request',
|
|
162
|
+
message: 'code or refresh_token is required',
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
const claims = decodeJwtPayload(tokenInput)
|
|
166
|
+
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
167
|
+
return jsonResponse(400, {
|
|
168
|
+
error: 'invalid_grant',
|
|
169
|
+
message: 'token has no hubUrl claim — was it minted by a hub that supports cloud-mode?',
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const target = `${normaliseHubUrl(claims.hubUrl)}/api/oauth2/token`
|
|
174
|
+
const upstream = await timedFetch(target, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
177
|
+
body: encodeFormUrlEncoded(body),
|
|
178
|
+
})
|
|
179
|
+
const text = await upstream.text()
|
|
180
|
+
return {
|
|
181
|
+
statusCode: upstream.status,
|
|
182
|
+
headers: { 'content-type': upstream.headers.get('content-type') || 'application/json' },
|
|
183
|
+
body: text,
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Smart Home directives ────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
async function handleDirective(event) {
|
|
190
|
+
const header = event.directive.header
|
|
191
|
+
const ns = `${header.namespace}/${header.name}`
|
|
192
|
+
if (ns === 'Alexa.Authorization/AcceptGrant') {
|
|
193
|
+
return handleAcceptGrant(event)
|
|
194
|
+
}
|
|
195
|
+
return forwardDirective(event)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function handleAcceptGrant(event) {
|
|
199
|
+
const header = event.directive.header
|
|
200
|
+
const payload = event.directive.payload || {}
|
|
201
|
+
const grantCode = payload.grant && payload.grant.code
|
|
202
|
+
const granteeToken = payload.grantee && payload.grantee.token
|
|
203
|
+
if (!grantCode || !granteeToken) {
|
|
204
|
+
return errorEnvelope(
|
|
205
|
+
header,
|
|
206
|
+
'ACCEPT_GRANT_FAILED',
|
|
207
|
+
'payload missing grant.code or grantee.token',
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
const claims = decodeJwtPayload(granteeToken)
|
|
211
|
+
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
212
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'grantee token has no hubUrl claim')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const lwaClientId = process.env.LWA_CLIENT_ID
|
|
216
|
+
const lwaClientSecret = process.env.LWA_CLIENT_SECRET
|
|
217
|
+
const redirectUri = process.env.LAMBDA_REDIRECT_URI
|
|
218
|
+
if (!lwaClientId || !lwaClientSecret || !redirectUri) {
|
|
219
|
+
return errorEnvelope(
|
|
220
|
+
header,
|
|
221
|
+
'ACCEPT_GRANT_FAILED',
|
|
222
|
+
'LWA_CLIENT_ID / LWA_CLIENT_SECRET / LAMBDA_REDIRECT_URI not set on the Lambda',
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Exchange Amazon's grant code for LWA tokens we can hand to the hub
|
|
227
|
+
// so it can hit the Event Gateway later (proactive doorbell push).
|
|
228
|
+
const lwaResp = await timedFetch('https://api.amazon.com/auth/o2/token', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
231
|
+
body: encodeFormUrlEncoded({
|
|
232
|
+
grant_type: 'authorization_code',
|
|
233
|
+
code: grantCode,
|
|
234
|
+
client_id: lwaClientId,
|
|
235
|
+
client_secret: lwaClientSecret,
|
|
236
|
+
redirect_uri: redirectUri,
|
|
237
|
+
}),
|
|
238
|
+
})
|
|
239
|
+
if (!lwaResp.ok) {
|
|
240
|
+
const text = await lwaResp.text().catch(() => '')
|
|
241
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', `LWA exchange ${lwaResp.status}: ${text}`)
|
|
242
|
+
}
|
|
243
|
+
const lwa = await lwaResp.json()
|
|
244
|
+
const expiresInSec = typeof lwa.expires_in === 'number' ? lwa.expires_in : 3600
|
|
245
|
+
|
|
246
|
+
// Push the tokens back to the hub. The hub authenticates this request
|
|
247
|
+
// via the bearer (= the same grantee token Alexa just gave us), so it
|
|
248
|
+
// can attribute the LWA tokens to the right operator user.
|
|
249
|
+
const hubUrl = normaliseHubUrl(claims.hubUrl)
|
|
250
|
+
const callback = `${hubUrl}/addon/export-alexa/oauth/lwa-tokens`
|
|
251
|
+
const callbackResp = await timedFetch(callback, {
|
|
252
|
+
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
|
+
},
|
|
260
|
+
body: JSON.stringify({
|
|
261
|
+
lwaAccessToken: lwa.access_token,
|
|
262
|
+
lwaRefreshToken: lwa.refresh_token,
|
|
263
|
+
expiresAt: Date.now() + expiresInSec * 1000,
|
|
264
|
+
}),
|
|
265
|
+
})
|
|
266
|
+
if (!callbackResp.ok) {
|
|
267
|
+
const text = await callbackResp.text().catch(() => '')
|
|
268
|
+
return errorEnvelope(
|
|
269
|
+
header,
|
|
270
|
+
'ACCEPT_GRANT_FAILED',
|
|
271
|
+
`hub callback ${callbackResp.status}: ${text}`,
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
event: {
|
|
277
|
+
header: {
|
|
278
|
+
namespace: 'Alexa.Authorization',
|
|
279
|
+
name: 'AcceptGrant.Response',
|
|
280
|
+
messageId: header.messageId,
|
|
281
|
+
payloadVersion: '3',
|
|
282
|
+
},
|
|
283
|
+
payload: {},
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function forwardDirective(event) {
|
|
289
|
+
const header = event.directive.header
|
|
290
|
+
const bearer = extractBearer(event.directive)
|
|
291
|
+
if (!bearer) {
|
|
292
|
+
return errorEnvelope(
|
|
293
|
+
header,
|
|
294
|
+
'INVALID_AUTHORIZATION_CREDENTIAL',
|
|
295
|
+
'directive has no bearer token',
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
const claims = decodeJwtPayload(bearer)
|
|
299
|
+
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
300
|
+
return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'bearer has no hubUrl claim')
|
|
301
|
+
}
|
|
302
|
+
const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
|
|
303
|
+
const directiveHeaders = {
|
|
304
|
+
'content-type': 'application/json',
|
|
305
|
+
authorization: `Bearer ${bearer}`,
|
|
306
|
+
// Defense-in-depth shared secret — the export-alexa "Ingress token"
|
|
307
|
+
// setting. Sent only when configured; the hub enforces it when the
|
|
308
|
+
// operator turns on "Require ingress token".
|
|
309
|
+
...(process.env.HUB_INGRESS_TOKEN
|
|
310
|
+
? { 'x-camstack-ingress': process.env.HUB_INGRESS_TOKEN }
|
|
311
|
+
: {}),
|
|
312
|
+
}
|
|
313
|
+
const resp = await timedFetch(target, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: directiveHeaders,
|
|
316
|
+
body: JSON.stringify(event),
|
|
317
|
+
})
|
|
318
|
+
const text = await resp.text()
|
|
319
|
+
try {
|
|
320
|
+
return JSON.parse(text)
|
|
321
|
+
} catch {
|
|
322
|
+
return errorEnvelope(header, 'INTERNAL_ERROR', `hub returned non-JSON: ${text.slice(0, 256)}`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function extractBearer(directive) {
|
|
327
|
+
if (
|
|
328
|
+
directive.endpoint &&
|
|
329
|
+
directive.endpoint.scope &&
|
|
330
|
+
typeof directive.endpoint.scope.token === 'string'
|
|
331
|
+
) {
|
|
332
|
+
return directive.endpoint.scope.token
|
|
333
|
+
}
|
|
334
|
+
if (
|
|
335
|
+
directive.payload &&
|
|
336
|
+
directive.payload.scope &&
|
|
337
|
+
typeof directive.payload.scope.token === 'string'
|
|
338
|
+
) {
|
|
339
|
+
return directive.payload.scope.token
|
|
340
|
+
}
|
|
341
|
+
return null
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
function decodeJwtPayload(token) {
|
|
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
|
+
}
|