@camstack/addon-export-alexa 1.2.132 → 1.2.133

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.
@@ -1,116 +1,171 @@
1
- # CamStack Alexa Lambda (Option 2 — single hosted skill)
1
+ # CamStack Alexa Lambda — skill endpoint + token broker
2
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.
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
- ## 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`.
9
+ The same code runs the built-in CamStack skill and any operator's own
10
+ ("custom") skill. Each deployment uses its own secrets.
28
11
 
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
12
+ ## What it does
50
13
 
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.
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). |
61
24
 
62
- ## Skill manifest fields
25
+ ### The two credential pairs
63
26
 
64
- In the [Alexa developer console](https://developer.amazon.com/alexa/console/ask)
65
- for your **Smart Home** skill:
27
+ | Pair | Secrets Manager id | Authenticates | Who else holds it |
28
+ | --- | --- | --- | --- |
29
+ | **LWA "Send Alexa Events"** client (skill console → Permissions → Alexa Skill Messaging) | `camstack/alexa/lwa` | this Lambda → `api.amazon.com` (code exchange, refresh) | nobody |
30
+ | **Account-linking** client (skill console → Account Linking) | `camstack/alexa/skill` | Amazon → this Lambda's `/token` | Amazon |
66
31
 
67
- ### 1. Endpoint
32
+ Each secret is a JSON string:
68
33
 
69
- | Field | Value |
70
- | ----------------- | ------------------------------------------------------------------ |
71
- | Default endpoint | `<LAMBDA_ARN>` (Function overview → Function ARN) |
34
+ ```json
35
+ { "clientId": "…", "clientSecret": "…" }
36
+ ```
72
37
 
73
- ### 2. Account Linking
38
+ `camstack/alexa/skill` may also carry `"previousClientSecret"` — accepted on
39
+ `/token` while you rotate it (change the value in the skill console, then remove
40
+ the previous one).
74
41
 
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)_ |
42
+ ### The broker credential
85
43
 
86
- ### 3. Permissions (proactive events)
44
+ `csb1.<linkId>.<secret>` — 128-bit id + 256-bit secret, minted per link at
45
+ AcceptGrant. The table stores only `sha256(secret)`; comparison is constant
46
+ time; an unknown link and a wrong secret get the same 401. It opens exactly one
47
+ link, is revoked by deleting the item, rotated by `/broker/rotate` or by any
48
+ re-link, and lapses after 180 days without a refresh (DynamoDB TTL).
87
49
 
88
- Enable **Send Alexa Events** and copy the generated LWA client id /
89
- secret into the Lambda env (`LWA_CLIENT_ID` / `LWA_CLIENT_SECRET`).
50
+ ## Files
90
51
 
91
- ## How the routing works
52
+ `index.js` (handler), `broker.js`, `secrets.js`, `token-store.js`, `util.js`,
53
+ `http.js`, `aws.js`. No `node_modules`: AWS SDK v3 ships with the nodejs22.x
54
+ runtime. Build:
92
55
 
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.
56
+ ```bash
57
+ cd lambda-template && rm -f ../camstack-alexa-lambda.zip && zip -j ../camstack-alexa-lambda.zip *.js
58
+ ```
100
59
 
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.
60
+ ## Deployment
105
61
 
106
- ## Local testing
62
+ It ships in the published `@camstack/addon-export-alexa` npm package (`npm pack`
63
+ it and take `package/lambda-template/`). Deploy it in the AWS region that serves
64
+ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-west-2`.
107
65
 
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
- ```
66
+ 1. **Lambda**: Node.js 22.x, arm64, handler `index.handler`, timeout 8 s or
67
+ more, 128–256 MB. Upload the zip.
68
+ 2. **Function URL**: auth type `NONE` (Amazon's account linking and the hubs
69
+ call it). Note the URL.
70
+ 3. **Trigger**: **Alexa** → **Alexa Smart Home**, with your skill id
71
+ (`amzn1.ask.skill.…`). Create the skill first to get it.
72
+ 4. **Secrets Manager**: create `camstack/alexa/lwa` and `camstack/alexa/skill`
73
+ (JSON above) in the Lambda's region. Enter the values in the console, or
74
+ with `--secret-string file://…` from a file you delete afterwards — never as
75
+ a command-line argument.
76
+ 5. **DynamoDB**:
77
+
78
+ ```bash
79
+ aws dynamodb create-table --table-name camstack-alexa-tokens \
80
+ --attribute-definitions AttributeName=linkId,AttributeType=S \
81
+ --key-schema AttributeName=linkId,KeyType=HASH \
82
+ --billing-mode PAY_PER_REQUEST \
83
+ --sse-specification Enabled=true,SSEType=KMS \
84
+ --deletion-protection-enabled
85
+ aws dynamodb wait table-exists --table-name camstack-alexa-tokens
86
+ aws dynamodb update-time-to-live --table-name camstack-alexa-tokens \
87
+ --time-to-live-specification Enabled=true,AttributeName=ttl
88
+ ```
89
+
90
+ 6. **IAM** — add to the Lambda's execution role (replace region/account):
91
+
92
+ ```json
93
+ {
94
+ "Version": "2012-10-17",
95
+ "Statement": [
96
+ {
97
+ "Sid": "ReadSkillSecrets",
98
+ "Effect": "Allow",
99
+ "Action": "secretsmanager:GetSecretValue",
100
+ "Resource": "arn:aws:secretsmanager:REGION:ACCOUNT:secret:camstack/alexa/*"
101
+ },
102
+ {
103
+ "Sid": "BrokerLinks",
104
+ "Effect": "Allow",
105
+ "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
106
+ "Resource": "arn:aws:dynamodb:REGION:ACCOUNT:table/camstack-alexa-tokens"
107
+ }
108
+ ]
109
+ }
110
+ ```
111
+
112
+ The default `aws/secretsmanager` and `aws/dynamodb` keys need no extra KMS
113
+ permission. A customer-managed key would need `kms:Decrypt` on it.
114
+
115
+ 7. **Environment variables** (none is a secret):
116
+
117
+ | Name | Value |
118
+ | --- | --- |
119
+ | `LWA_SECRET_ID` | `camstack/alexa/lwa` |
120
+ | `SKILL_SECRET_ID` | `camstack/alexa/skill` |
121
+ | `TOKENS_TABLE` | `camstack-alexa-tokens`. Unset = broker off: AcceptGrant hands the tokens to the hub as before D753. |
122
+ | `LAMBDA_REDIRECT_URI` | Function URL + `/token`. AcceptGrant's `redirect_uri`; its origin is the broker URL advertised to hubs. |
123
+ | `BROKER_PUBLIC_URL` | Optional override of that origin (https only). |
124
+ | `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. |
125
+ | `ALLOW_MIGRATION` | `0` = refuse `/broker/migrate`. Default on until every hub has migrated. |
126
+ | `HUB_INGRESS_TOKEN` | Optional defense-in-depth header (`X-CamStack-Ingress`), copied from the addon's **Ingress token** setting. |
127
+
128
+ Legacy, read only when the matching `*_SECRET_ID` is unset: `LWA_CLIENT_ID`,
129
+ `LWA_CLIENT_SECRET`, `SKILL_CLIENT_ID`, `SKILL_CLIENT_SECRET`. Remove them once
130
+ the secrets are in Secrets Manager.
131
+
132
+ ## Skill manifest
133
+
134
+ | Section | Field | Value |
135
+ | --- | --- | --- |
136
+ | Smart Home | Default endpoint | the Lambda ARN |
137
+ | Account Linking | Authorization grant type | Auth Code Grant |
138
+ | | Authorization URI | `<FUNCTION_URL>/authorize` |
139
+ | | Access Token URI | `<FUNCTION_URL>/token` |
140
+ | | Client ID / Secret | the `camstack/alexa/skill` values |
141
+ | | Authentication scheme | Credentials in request body (**not** HTTP Basic — the Lambda reads them from the body) |
142
+ | | Scope, Domain list | empty |
143
+ | Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into `camstack/alexa/lwa` |
144
+
145
+ ## Cut-over and compatibility
146
+
147
+ | Lambda | Hub addon | Result |
148
+ | --- | --- | --- |
149
+ | new, broker on | new | Broker links. Hub holds no secret. |
150
+ | 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`. |
151
+ | new, broker off (`TOKENS_TABLE` unset) | any | Exactly the pre-D753 behaviour. |
152
+ | 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.** |
153
+
154
+ A hub linked before D753 migrates itself: the first directive after the Lambda
155
+ advertises `X-CamStack-Broker` lets it trade its refresh token for a broker
156
+ link (`/broker/migrate`, which proves the token by refreshing it with the
157
+ skill's secret) and delete its copy.
158
+
159
+ ## Logs
160
+
161
+ CloudWatch. `timedFetch_timing` (one per outbound call: host, path, status,
162
+ timings), `accept_grant: linked via broker`, `broker: migrated a pre-broker
163
+ grant`, `broker: credential rotated`, `broker: link revoked by its hub`,
164
+ `broker: grant revoked by Amazon; link removed`, `broker: request failed`. No
165
+ log line carries a token, code, credential or secret.
166
+
167
+ ## Tests
168
+
169
+ `packages/addon-export-alexa/src/__tests__/lambda-broker.spec.ts` — the whole
170
+ Lambda against fake LWA, hub, Secrets Manager and DynamoDB (`npx vitest run`
171
+ 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 }