@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
|
@@ -1,116 +1,179 @@
|
|
|
1
|
-
# CamStack Alexa Lambda
|
|
1
|
+
# CamStack Alexa Lambda — skill endpoint + token broker
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
One Lambda serves one Alexa Smart Home skill and every CamStack hub linked to
|
|
4
|
+
it. It routes each request to the right hub by the `hubUrl` claim the hub bakes
|
|
5
|
+
into its OAuth tokens, and — since D753 — it is the **only** place the skill's
|
|
6
|
+
Login With Amazon (LWA) client secret lives. Hubs never hold it: they receive
|
|
7
|
+
short-lived Event Gateway tokens from this Lambda's **token broker**.
|
|
8
8
|
|
|
9
|
-
|
|
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).
|
|
9
|
+
The same code runs the built-in CamStack skill and any operator's own
|
|
10
|
+
("custom") skill. Each deployment uses its own secrets.
|
|
48
11
|
|
|
49
|
-
##
|
|
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
|
|
12
|
+
## What it does
|
|
68
13
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
14
|
+
| Invocation | Action |
|
|
15
|
+
| --- | --- |
|
|
16
|
+
| `GET <FUNCTION_URL>/authorize` | HTML form asking for the hub URL, then redirects to the hub's OAuth `/authorize`. |
|
|
17
|
+
| `POST <FUNCTION_URL>/token` | Checks the account-linking client id/secret, decodes the `hubUrl` claim, proxies the token request to the hub. |
|
|
18
|
+
| Smart Home directive | Forwards to `${hubUrl}/addon/export-alexa/directive`, adding `X-CamStack-Broker: <broker origin>` when the broker is on. |
|
|
19
|
+
| `Alexa.Authorization/AcceptGrant` | Exchanges the grant code at `api.amazon.com` **here**, stores the grant in DynamoDB, gives the hub a per-link broker credential (`POST /addon/export-alexa/oauth/broker-link`). |
|
|
20
|
+
| `POST <FUNCTION_URL>/broker/token` | `Bearer <credential>` → `{ accessToken, expiresAt }` (refreshed here when < 5 min left). |
|
|
21
|
+
| `POST <FUNCTION_URL>/broker/migrate` | `{ refreshToken }` from a hub linked before D753 → a broker link. |
|
|
22
|
+
| `POST <FUNCTION_URL>/broker/rotate` | `Bearer <credential>` → `{ brokerCredential }`; the old one stops working. |
|
|
23
|
+
| `POST <FUNCTION_URL>/broker/revoke` | `Bearer <credential>` → deletes the link (and the refresh token). |
|
|
24
|
+
|
|
25
|
+
Errors Alexa receives follow the Smart Home contract: every `Alexa.ErrorResponse`
|
|
26
|
+
carries the directive's `correlationToken` and `endpoint`; a hub that cannot be
|
|
27
|
+
reached (network error, or a tunnel's 502–504 / 52x page) is
|
|
28
|
+
`BRIDGE_UNREACHABLE`; a hub reply that is not an Alexa event becomes
|
|
29
|
+
`INTERNAL_ERROR`; AcceptGrant failures are `Alexa.Authorization` /
|
|
30
|
+
`ACCEPT_GRANT_FAILED`. The `/authorize` page accepts only an `https://` hub URL
|
|
31
|
+
and shows its error on the page.
|
|
32
|
+
|
|
33
|
+
### The two credential pairs
|
|
34
|
+
|
|
35
|
+
| Pair | Secrets Manager id | Authenticates | Who else holds it |
|
|
36
|
+
| --- | --- | --- | --- |
|
|
37
|
+
| **LWA "Send Alexa Events"** client (skill console → Permissions → Alexa Skill Messaging) | `camstack/alexa/lwa` | this Lambda → `api.amazon.com` (code exchange, refresh) | nobody |
|
|
38
|
+
| **Account-linking** client (skill console → Account Linking) | `camstack/alexa/skill` | Amazon → this Lambda's `/token` | Amazon |
|
|
39
|
+
|
|
40
|
+
Each secret is a JSON string:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{ "clientId": "…", "clientSecret": "…" }
|
|
44
|
+
```
|
|
72
45
|
|
|
73
|
-
|
|
46
|
+
`camstack/alexa/skill` may also carry `"previousClientSecret"` — accepted on
|
|
47
|
+
`/token` while you rotate it (change the value in the skill console, then remove
|
|
48
|
+
the previous one).
|
|
74
49
|
|
|
75
|
-
|
|
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)_ |
|
|
50
|
+
### The broker credential
|
|
85
51
|
|
|
86
|
-
|
|
52
|
+
`csb1.<linkId>.<secret>` — 128-bit id + 256-bit secret, minted per link at
|
|
53
|
+
AcceptGrant. The table stores only `sha256(secret)`; comparison is constant
|
|
54
|
+
time; an unknown link and a wrong secret get the same 401. It opens exactly one
|
|
55
|
+
link, is revoked by deleting the item, rotated by `/broker/rotate` or by any
|
|
56
|
+
re-link, and lapses after 180 days without a refresh (DynamoDB TTL).
|
|
87
57
|
|
|
88
|
-
|
|
89
|
-
secret into the Lambda env (`LWA_CLIENT_ID` / `LWA_CLIENT_SECRET`).
|
|
58
|
+
## Files
|
|
90
59
|
|
|
91
|
-
|
|
60
|
+
`index.js` (handler), `broker.js`, `secrets.js`, `token-store.js`, `util.js`,
|
|
61
|
+
`http.js`, `aws.js`. No `node_modules`: AWS SDK v3 ships with the nodejs22.x
|
|
62
|
+
runtime. Build:
|
|
92
63
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
64
|
+
```bash
|
|
65
|
+
cd lambda-template && rm -f ../camstack-alexa-lambda.zip && zip -j ../camstack-alexa-lambda.zip *.js
|
|
66
|
+
```
|
|
100
67
|
|
|
101
|
-
|
|
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.
|
|
68
|
+
## Deployment
|
|
105
69
|
|
|
106
|
-
|
|
70
|
+
It ships in the published `@camstack/addon-export-alexa` npm package (`npm pack`
|
|
71
|
+
it and take `package/lambda-template/`). Deploy it in the AWS region that serves
|
|
72
|
+
your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-west-2`.
|
|
107
73
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
74
|
+
1. **Lambda**: Node.js 22.x, arm64, handler `index.handler`, timeout 8 s or
|
|
75
|
+
more, 128–256 MB. Upload the zip.
|
|
76
|
+
2. **Function URL**: auth type `NONE` (Amazon's account linking and the hubs
|
|
77
|
+
call it). Note the URL.
|
|
78
|
+
3. **Trigger**: **Alexa** → **Alexa Smart Home**, with your skill id
|
|
79
|
+
(`amzn1.ask.skill.…`). Create the skill first to get it.
|
|
80
|
+
4. **Secrets Manager**: create `camstack/alexa/lwa` and `camstack/alexa/skill`
|
|
81
|
+
(JSON above) in the Lambda's region. Enter the values in the console, or
|
|
82
|
+
with `--secret-string file://…` from a file you delete afterwards — never as
|
|
83
|
+
a command-line argument.
|
|
84
|
+
5. **DynamoDB**:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
aws dynamodb create-table --table-name camstack-alexa-tokens \
|
|
88
|
+
--attribute-definitions AttributeName=linkId,AttributeType=S \
|
|
89
|
+
--key-schema AttributeName=linkId,KeyType=HASH \
|
|
90
|
+
--billing-mode PAY_PER_REQUEST \
|
|
91
|
+
--sse-specification Enabled=true,SSEType=KMS \
|
|
92
|
+
--deletion-protection-enabled
|
|
93
|
+
aws dynamodb wait table-exists --table-name camstack-alexa-tokens
|
|
94
|
+
aws dynamodb update-time-to-live --table-name camstack-alexa-tokens \
|
|
95
|
+
--time-to-live-specification Enabled=true,AttributeName=ttl
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
6. **IAM** — add to the Lambda's execution role (replace region/account):
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"Version": "2012-10-17",
|
|
103
|
+
"Statement": [
|
|
104
|
+
{
|
|
105
|
+
"Sid": "ReadSkillSecrets",
|
|
106
|
+
"Effect": "Allow",
|
|
107
|
+
"Action": "secretsmanager:GetSecretValue",
|
|
108
|
+
"Resource": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:camstack/alexa/*"
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"Sid": "BrokerLinks",
|
|
112
|
+
"Effect": "Allow",
|
|
113
|
+
"Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
|
|
114
|
+
"Resource": "arn:aws:dynamodb:REGION:ACCOUNT:table/camstack-alexa-tokens"
|
|
115
|
+
}
|
|
116
|
+
]
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The default `aws/secretsmanager` and `aws/dynamodb` keys need no extra KMS
|
|
121
|
+
permission. A customer-managed key would need `kms:Decrypt` on it.
|
|
122
|
+
|
|
123
|
+
7. **Environment variables** (none is a secret):
|
|
124
|
+
|
|
125
|
+
| Name | Value |
|
|
126
|
+
| --- | --- |
|
|
127
|
+
| `LWA_SECRET_ID` | `camstack/alexa/lwa` |
|
|
128
|
+
| `SKILL_SECRET_ID` | `camstack/alexa/skill` |
|
|
129
|
+
| `TOKENS_TABLE` | `camstack-alexa-tokens`. Unset = broker off: AcceptGrant hands the tokens to the hub as before D753. |
|
|
130
|
+
| `LAMBDA_REDIRECT_URI` | Function URL + `/token`. AcceptGrant's `redirect_uri`; its origin is the broker URL advertised to hubs. |
|
|
131
|
+
| `BROKER_PUBLIC_URL` | Optional override of that origin (https only). |
|
|
132
|
+
| `LEGACY_HUB_FALLBACK` | `0` = a hub without `/oauth/broker-link` (pre-D753 addon) fails AcceptGrant instead of receiving the refresh token. Default on, for the cut-over. |
|
|
133
|
+
| `ALLOW_MIGRATION` | `0` = refuse `/broker/migrate`. Default on until every hub has migrated. |
|
|
134
|
+
| `HUB_INGRESS_TOKEN` | Optional defense-in-depth header (`X-CamStack-Ingress`), copied from the addon's **Ingress token** setting. |
|
|
135
|
+
|
|
136
|
+
Legacy, read only when the matching `*_SECRET_ID` is unset: `LWA_CLIENT_ID`,
|
|
137
|
+
`LWA_CLIENT_SECRET`, `SKILL_CLIENT_ID`, `SKILL_CLIENT_SECRET`. Remove them once
|
|
138
|
+
the secrets are in Secrets Manager.
|
|
139
|
+
|
|
140
|
+
## Skill manifest
|
|
141
|
+
|
|
142
|
+
| Section | Field | Value |
|
|
143
|
+
| --- | --- | --- |
|
|
144
|
+
| Smart Home | Default endpoint | the Lambda ARN |
|
|
145
|
+
| Account Linking | Authorization grant type | Auth Code Grant |
|
|
146
|
+
| | Authorization URI | `<FUNCTION_URL>/authorize` |
|
|
147
|
+
| | Access Token URI | `<FUNCTION_URL>/token` |
|
|
148
|
+
| | Client ID / Secret | the `camstack/alexa/skill` values |
|
|
149
|
+
| | Authentication scheme | Credentials in request body (**not** HTTP Basic — the Lambda reads them from the body) |
|
|
150
|
+
| | Scope, Domain list | empty |
|
|
151
|
+
| Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into `camstack/alexa/lwa` |
|
|
152
|
+
|
|
153
|
+
## Cut-over and compatibility
|
|
154
|
+
|
|
155
|
+
| Lambda | Hub addon | Result |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| new, broker on | new | Broker links. Hub holds no secret. |
|
|
158
|
+
| new, broker on | old (pre-D753) | AcceptGrant falls back to the legacy hand-off (hub 404 on `/oauth/broker-link`) unless `LEGACY_HUB_FALLBACK=0`. |
|
|
159
|
+
| new, broker off (`TOKENS_TABLE` unset) | any | Exactly the pre-D753 behaviour. |
|
|
160
|
+
| old | new | Links, but the hub cannot renew the grant (no secret): events stop after ~1 h until the Lambda is upgraded. **Upgrade the Lambda first.** |
|
|
161
|
+
|
|
162
|
+
A hub linked before D753 migrates itself: the first directive after the Lambda
|
|
163
|
+
advertises `X-CamStack-Broker` lets it trade its refresh token for a broker
|
|
164
|
+
link (`/broker/migrate`, which proves the token by refreshing it with the
|
|
165
|
+
skill's secret) and delete its copy.
|
|
166
|
+
|
|
167
|
+
## Logs
|
|
168
|
+
|
|
169
|
+
CloudWatch. `timedFetch_timing` (one per outbound call: host, path, status,
|
|
170
|
+
timings), `accept_grant: linked via broker`, `broker: migrated a pre-broker
|
|
171
|
+
grant`, `broker: credential rotated`, `broker: link revoked by its hub`,
|
|
172
|
+
`broker: grant revoked by Amazon; link removed`, `broker: request failed`. No
|
|
173
|
+
log line carries a token, code, credential or secret.
|
|
174
|
+
|
|
175
|
+
## Tests
|
|
176
|
+
|
|
177
|
+
`packages/addon-export-alexa/src/__tests__/lambda-broker.spec.ts` — the whole
|
|
178
|
+
Lambda against fake LWA, hub, Secrets Manager and DynamoDB (`npx vitest run`
|
|
179
|
+
in the addon).
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default AWS wiring. The Lambda Node.js 22 runtime ships AWS SDK v3, so the
|
|
3
|
+
* deployment stays a zip of plain files with no node_modules. Required lazily:
|
|
4
|
+
* tests inject fakes and never load the SDK.
|
|
5
|
+
*
|
|
6
|
+
* Execution-role permissions this needs (see README → IAM):
|
|
7
|
+
* secretsmanager:GetSecretValue on the two camstack/alexa/* secrets
|
|
8
|
+
* dynamodb:GetItem/PutItem/DeleteItem on the tokens table
|
|
9
|
+
*/
|
|
10
|
+
'use strict'
|
|
11
|
+
|
|
12
|
+
function createAwsClients() {
|
|
13
|
+
let ddbClient = null
|
|
14
|
+
let smClient = null
|
|
15
|
+
|
|
16
|
+
async function send(commandName, input) {
|
|
17
|
+
const ddb = require('@aws-sdk/client-dynamodb')
|
|
18
|
+
if (!ddbClient) ddbClient = new ddb.DynamoDBClient({})
|
|
19
|
+
const Command = ddb[`${commandName}Command`]
|
|
20
|
+
if (typeof Command !== 'function') throw new Error(`unknown DynamoDB command ${commandName}`)
|
|
21
|
+
return ddbClient.send(new Command(input))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fetchSecretString(secretId) {
|
|
25
|
+
const sm = require('@aws-sdk/client-secrets-manager')
|
|
26
|
+
if (!smClient) smClient = new sm.SecretsManagerClient({})
|
|
27
|
+
const res = await smClient.send(new sm.GetSecretValueCommand({ SecretId: secretId }))
|
|
28
|
+
if (typeof res.SecretString !== 'string') {
|
|
29
|
+
throw new Error(`secret ${secretId} has no SecretString`)
|
|
30
|
+
}
|
|
31
|
+
return res.SecretString
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { send, fetchSecretString }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { createAwsClients }
|