@camstack/addon-export-alexa 1.2.134 → 1.2.136
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 +146 -482
- package/dist/export-alexa.addon.mjs +146 -482
- package/lambda-template/README.md +49 -21
- package/lambda-template/aws.js +32 -16
- package/lambda-template/broker.js +3 -3
- package/lambda-template/debug-trace.js +100 -0
- package/lambda-template/index.js +19 -8
- package/lambda-template/secrets.js +24 -21
- package/package.json +1 -1
|
@@ -32,18 +32,19 @@ and shows its error on the page.
|
|
|
32
32
|
|
|
33
33
|
### The two credential pairs
|
|
34
34
|
|
|
35
|
-
| Pair |
|
|
35
|
+
| Pair | SSM parameter (SecureString) | Authenticates | Who else holds it |
|
|
36
36
|
| --- | --- | --- | --- |
|
|
37
|
-
| **LWA "Send Alexa Events"** client (skill console → Permissions → Alexa Skill Messaging) |
|
|
38
|
-
| **Account-linking** client (skill console → Account Linking) |
|
|
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
39
|
|
|
40
|
-
Each
|
|
40
|
+
Each parameter is a **SecureString** (standard tier, AWS-managed key
|
|
41
|
+
`alias/aws/ssm`) whose value is a JSON string:
|
|
41
42
|
|
|
42
43
|
```json
|
|
43
44
|
{ "clientId": "…", "clientSecret": "…" }
|
|
44
45
|
```
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
`/camstack/alexa/skill` may also carry `"previousClientSecret"` — accepted on
|
|
47
48
|
`/token` while you rotate it (change the value in the skill console, then remove
|
|
48
49
|
the previous one).
|
|
49
50
|
|
|
@@ -77,10 +78,18 @@ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-we
|
|
|
77
78
|
call it). Note the URL.
|
|
78
79
|
3. **Trigger**: **Alexa** → **Alexa Smart Home**, with your skill id
|
|
79
80
|
(`amzn1.ask.skill.…`). Create the skill first to get it.
|
|
80
|
-
4. **
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
4. **SSM Parameter Store** (free standard tier): in the Lambda's region,
|
|
82
|
+
Systems Manager → Parameter Store → **Create parameter**, twice:
|
|
83
|
+
- Name `/camstack/alexa/lwa`, then `/camstack/alexa/skill`
|
|
84
|
+
- Tier **Standard**, Type **SecureString**
|
|
85
|
+
- KMS key source **My current account**, KMS key ID `alias/aws/ssm`
|
|
86
|
+
- Value: the JSON above.
|
|
87
|
+
|
|
88
|
+
Enter the values in the console, or with `aws ssm put-parameter --type
|
|
89
|
+
SecureString --value file://…` from a file you delete afterwards — never as
|
|
90
|
+
a literal command-line argument. The Lambda reads them with `GetParameter`
|
|
91
|
+
and `WithDecryption: true`, caches them for 5 minutes, and re-reads the LWA
|
|
92
|
+
pair at once when LWA answers `invalid_client`.
|
|
84
93
|
5. **DynamoDB**:
|
|
85
94
|
|
|
86
95
|
```bash
|
|
@@ -102,10 +111,22 @@ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-we
|
|
|
102
111
|
"Version": "2012-10-17",
|
|
103
112
|
"Statement": [
|
|
104
113
|
{
|
|
105
|
-
"Sid": "
|
|
114
|
+
"Sid": "ReadSkillParameters",
|
|
106
115
|
"Effect": "Allow",
|
|
107
|
-
"Action": "
|
|
108
|
-
"Resource": "arn:aws:
|
|
116
|
+
"Action": "ssm:GetParameter",
|
|
117
|
+
"Resource": "arn:aws:ssm:REGION:ACCOUNT:parameter/camstack/alexa/*"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"Sid": "DecryptSkillParametersViaSsm",
|
|
121
|
+
"Effect": "Allow",
|
|
122
|
+
"Action": "kms:Decrypt",
|
|
123
|
+
"Resource": "arn:aws:kms:REGION:ACCOUNT:key/AWS_SSM_KEY_ID",
|
|
124
|
+
"Condition": {
|
|
125
|
+
"StringEquals": { "kms:ViaService": "ssm.REGION.amazonaws.com" },
|
|
126
|
+
"StringLike": {
|
|
127
|
+
"kms:EncryptionContext:PARAMETER_ARN": "arn:aws:ssm:REGION:ACCOUNT:parameter/camstack/alexa/*"
|
|
128
|
+
}
|
|
129
|
+
}
|
|
109
130
|
},
|
|
110
131
|
{
|
|
111
132
|
"Sid": "BrokerLinks",
|
|
@@ -117,25 +138,32 @@ your skill's locale: NA → `us-east-1`, EU/IN → `eu-west-1`, FE/AU → `us-we
|
|
|
117
138
|
}
|
|
118
139
|
```
|
|
119
140
|
|
|
120
|
-
|
|
121
|
-
|
|
141
|
+
`AWS_SSM_KEY_ID` is the id of the AWS-managed `alias/aws/ssm` key (KMS
|
|
142
|
+
console → AWS managed keys, or `aws kms describe-key --key-id
|
|
143
|
+
alias/aws/ssm`). AWS's guidance on whether the AWS-managed key needs a
|
|
144
|
+
`kms:Decrypt` grant is inconsistent — its key policy already lets account
|
|
145
|
+
principals decrypt through SSM — so the statement is included, and scoped so
|
|
146
|
+
it can only decrypt these two parameters, and only through SSM. A
|
|
147
|
+
customer-managed key would need the same statement on that key instead. The
|
|
148
|
+
default `aws/dynamodb` key needs no extra permission.
|
|
122
149
|
|
|
123
150
|
7. **Environment variables** (none is a secret):
|
|
124
151
|
|
|
125
152
|
| Name | Value |
|
|
126
153
|
| --- | --- |
|
|
127
|
-
| `
|
|
128
|
-
| `
|
|
154
|
+
| `LWA_PARAM` | `/camstack/alexa/lwa` |
|
|
155
|
+
| `SKILL_PARAM` | `/camstack/alexa/skill` |
|
|
129
156
|
| `TOKENS_TABLE` | `camstack-alexa-tokens`. Unset = broker off: AcceptGrant hands the tokens to the hub as before D753. |
|
|
130
157
|
| `LAMBDA_REDIRECT_URI` | Function URL + `/token`. AcceptGrant's `redirect_uri`; its origin is the broker URL advertised to hubs. |
|
|
131
158
|
| `BROKER_PUBLIC_URL` | Optional override of that origin (https only). |
|
|
132
159
|
| `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. |
|
|
160
|
+
| `DEBUG_DIRECTIVES` | `1` = one `directive_trace` log line per Smart Home directive: namespace / name / endpointId / payloadVersion / payload keys asked, and the answered event (ErrorResponse type and message, StateReport property names and value shapes). Never the bearer token or SDP bodies. Diagnostic; default off. |
|
|
133
161
|
| `ALLOW_MIGRATION` | `0` = refuse `/broker/migrate`. Default on until every hub has migrated. |
|
|
134
162
|
| `HUB_INGRESS_TOKEN` | Optional defense-in-depth header (`X-CamStack-Ingress`), copied from the addon's **Ingress token** setting. |
|
|
135
163
|
|
|
136
|
-
Legacy, read only when the matching `*
|
|
164
|
+
Legacy, read only when the matching `*_PARAM` is unset: `LWA_CLIENT_ID`,
|
|
137
165
|
`LWA_CLIENT_SECRET`, `SKILL_CLIENT_ID`, `SKILL_CLIENT_SECRET`. Remove them once
|
|
138
|
-
the
|
|
166
|
+
the parameters exist and `*_PARAM` is set.
|
|
139
167
|
|
|
140
168
|
## Skill manifest
|
|
141
169
|
|
|
@@ -145,10 +173,10 @@ the secrets are in Secrets Manager.
|
|
|
145
173
|
| Account Linking | Authorization grant type | Auth Code Grant |
|
|
146
174
|
| | Authorization URI | `<FUNCTION_URL>/authorize` |
|
|
147
175
|
| | Access Token URI | `<FUNCTION_URL>/token` |
|
|
148
|
-
| | Client ID / Secret | the
|
|
176
|
+
| | Client ID / Secret | the `/camstack/alexa/skill` values |
|
|
149
177
|
| | Authentication scheme | Credentials in request body (**not** HTTP Basic — the Lambda reads them from the body) |
|
|
150
178
|
| | Scope, Domain list | empty |
|
|
151
|
-
| Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into
|
|
179
|
+
| Permissions | Send Alexa Events | on; its Alexa Client Id/Secret go into `/camstack/alexa/lwa` |
|
|
152
180
|
|
|
153
181
|
## Cut-over and compatibility
|
|
154
182
|
|
|
@@ -175,5 +203,5 @@ log line carries a token, code, credential or secret.
|
|
|
175
203
|
## Tests
|
|
176
204
|
|
|
177
205
|
`packages/addon-export-alexa/src/__tests__/lambda-broker.spec.ts` — the whole
|
|
178
|
-
Lambda against fake LWA, hub,
|
|
206
|
+
Lambda against fake LWA, hub, SSM Parameter Store and DynamoDB (`npx vitest run`
|
|
179
207
|
in the addon).
|
package/lambda-template/aws.js
CHANGED
|
@@ -1,37 +1,53 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Default AWS wiring. The Lambda Node.js 22 runtime ships AWS SDK v3
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Default AWS wiring. The Lambda Node.js 22 runtime ships AWS SDK v3
|
|
3
|
+
* (`@aws-sdk/client-ssm`, `@aws-sdk/client-dynamodb`), so the deployment stays
|
|
4
|
+
* a zip of plain files with no node_modules. Required lazily: tests inject a
|
|
5
|
+
* fake `loadSdk` and never load the SDK.
|
|
5
6
|
*
|
|
6
7
|
* Execution-role permissions this needs (see README → IAM):
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* ssm:GetParameter on parameter/camstack/alexa/*
|
|
9
|
+
* kms:Decrypt on the `aws/ssm` key, only via SSM and
|
|
10
|
+
* only for those parameters (D761)
|
|
11
|
+
* dynamodb:GetItem/PutItem/DeleteItem on the tokens table
|
|
9
12
|
*/
|
|
10
13
|
'use strict'
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
/**
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {(name: string) => any} [opts.loadSdk] module loader (tests inject a fake)
|
|
18
|
+
*/
|
|
19
|
+
function createAwsClients(opts) {
|
|
20
|
+
const loadSdk = (opts && opts.loadSdk) || require
|
|
13
21
|
let ddbClient = null
|
|
14
|
-
let
|
|
22
|
+
let ssmClient = null
|
|
23
|
+
let ssmSdk = null
|
|
15
24
|
|
|
16
25
|
async function send(commandName, input) {
|
|
17
|
-
const ddb =
|
|
26
|
+
const ddb = loadSdk('@aws-sdk/client-dynamodb')
|
|
18
27
|
if (!ddbClient) ddbClient = new ddb.DynamoDBClient({})
|
|
19
28
|
const Command = ddb[`${commandName}Command`]
|
|
20
29
|
if (typeof Command !== 'function') throw new Error(`unknown DynamoDB command ${commandName}`)
|
|
21
30
|
return ddbClient.send(new Command(input))
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The decrypted value of one SecureString parameter. Errors name the
|
|
35
|
+
* parameter, never a value.
|
|
36
|
+
*/
|
|
37
|
+
async function readParameter(name) {
|
|
38
|
+
if (!ssmSdk) ssmSdk = loadSdk('@aws-sdk/client-ssm')
|
|
39
|
+
if (!ssmClient) ssmClient = new ssmSdk.SSMClient({})
|
|
40
|
+
const res = await ssmClient.send(
|
|
41
|
+
new ssmSdk.GetParameterCommand({ Name: name, WithDecryption: true }),
|
|
42
|
+
)
|
|
43
|
+
const value = res && res.Parameter ? res.Parameter.Value : undefined
|
|
44
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
45
|
+
throw new Error(`parameter ${name} has no value`)
|
|
30
46
|
}
|
|
31
|
-
return
|
|
47
|
+
return value
|
|
32
48
|
}
|
|
33
49
|
|
|
34
|
-
return { send,
|
|
50
|
+
return { send, readParameter }
|
|
35
51
|
}
|
|
36
52
|
|
|
37
53
|
module.exports = { createAwsClients }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* The skill's LWA client secret authenticates every grant exchange and every
|
|
5
5
|
* refresh at api.amazon.com. Before D753 a copy of it lived in every hub — in
|
|
6
6
|
* public source and in a public npm package. Now it lives ONLY here (read from
|
|
7
|
-
*
|
|
7
|
+
* an SSM SecureString parameter), together with each user's LWA refresh token, and a hub
|
|
8
8
|
* gets exactly what it needs to push events: a short-lived access token.
|
|
9
9
|
*
|
|
10
10
|
* A hub authenticates to the broker with a PER-LINK credential
|
|
@@ -119,7 +119,7 @@ function lwaErrorCode(res) {
|
|
|
119
119
|
|
|
120
120
|
/**
|
|
121
121
|
* One call to the LWA token endpoint with the skill's credentials. On
|
|
122
|
-
* `invalid_client` with a
|
|
122
|
+
* `invalid_client` with a Parameter-Store-sourced secret, drops the cache and
|
|
123
123
|
* retries once with a freshly read value — so a rotation takes effect on the
|
|
124
124
|
* very next call instead of after the cache TTL.
|
|
125
125
|
*/
|
|
@@ -129,7 +129,7 @@ async function lwaTokenCall(deps, params) {
|
|
|
129
129
|
throw new BrokerError(500, 'unconfigured', 'LWA client credentials are not configured')
|
|
130
130
|
}
|
|
131
131
|
let res = await postLwa(deps, params, creds)
|
|
132
|
-
if (lwaErrorCode(res) === 'invalid_client' && creds.source === '
|
|
132
|
+
if (lwaErrorCode(res) === 'invalid_client' && creds.source === 'ssm') {
|
|
133
133
|
deps.secrets.invalidate('lwa')
|
|
134
134
|
const fresh = await deps.secrets.lwa()
|
|
135
135
|
if (fresh && fresh.clientSecret !== creds.clientSecret) {
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TEMPORARY diagnostic, on only with `DEBUG_DIRECTIVES=1`: one `directive_trace`
|
|
5
|
+
* line per Smart Home directive — what Alexa asked and what the skill answered.
|
|
6
|
+
*
|
|
7
|
+
* Built from an allow-list of fields, never by dumping the event: the
|
|
8
|
+
* directive's `scope.token` (the account's bearer) and the SDP bodies never
|
|
9
|
+
* reach the log. StateReport property values are summarised (a short scalar,
|
|
10
|
+
* or the object's keys and scalar values) — they are device states, not
|
|
11
|
+
* credentials.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const MAX_VALUE_CHARS = 60
|
|
15
|
+
|
|
16
|
+
function isEnabled(env) {
|
|
17
|
+
return Boolean(env) && env.DEBUG_DIRECTIVES === '1'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function str(value) {
|
|
21
|
+
return typeof value === 'string' ? value : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function summariseValue(value) {
|
|
25
|
+
if (value === null || value === undefined) return String(value)
|
|
26
|
+
if (typeof value !== 'object') return String(value).slice(0, MAX_VALUE_CHARS)
|
|
27
|
+
if (Array.isArray(value)) return `[array:${value.length}]`
|
|
28
|
+
const parts = Object.keys(value).map((k) => {
|
|
29
|
+
const v = value[k]
|
|
30
|
+
return v !== null && typeof v === 'object' ? `${k}:{…}` : `${k}:${String(v)}`
|
|
31
|
+
})
|
|
32
|
+
return `{${parts.join(',')}}`.slice(0, MAX_VALUE_CHARS)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function payloadShape(payload) {
|
|
36
|
+
if (!payload || typeof payload !== 'object') return undefined
|
|
37
|
+
return Object.keys(payload)
|
|
38
|
+
.filter((k) => k !== 'scope')
|
|
39
|
+
.map((k) => {
|
|
40
|
+
const v = payload[k]
|
|
41
|
+
if (v && typeof v === 'object' && typeof v.value === 'string') {
|
|
42
|
+
// RTC offers/answers: { format, value: <SDP> } — length only.
|
|
43
|
+
return `${k}(${str(v.format) || 'object'},${v.value.length}b)`
|
|
44
|
+
}
|
|
45
|
+
return k
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function summariseDirective(event) {
|
|
50
|
+
const d = (event && event.directive) || {}
|
|
51
|
+
const h = d.header || {}
|
|
52
|
+
const ep = d.endpoint || {}
|
|
53
|
+
return {
|
|
54
|
+
namespace: str(h.namespace),
|
|
55
|
+
name: str(h.name),
|
|
56
|
+
endpointId: str(ep.endpointId),
|
|
57
|
+
payloadVersion: str(h.payloadVersion),
|
|
58
|
+
payloadKeys: payloadShape(d.payload),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function summariseResponse(body) {
|
|
63
|
+
const ev = body && body.event
|
|
64
|
+
if (!ev || !ev.header) return { kind: 'non-event' }
|
|
65
|
+
const out = {
|
|
66
|
+
namespace: str(ev.header.namespace),
|
|
67
|
+
name: str(ev.header.name),
|
|
68
|
+
endpointId: str(ev.endpoint && ev.endpoint.endpointId),
|
|
69
|
+
}
|
|
70
|
+
const payload = ev.payload || {}
|
|
71
|
+
if (out.name === 'ErrorResponse') {
|
|
72
|
+
out.errorType = str(payload.type)
|
|
73
|
+
out.errorMessage = str(payload.message)
|
|
74
|
+
} else {
|
|
75
|
+
out.payloadKeys = payloadShape(payload)
|
|
76
|
+
}
|
|
77
|
+
const props = body.context && Array.isArray(body.context.properties) ? body.context.properties : null
|
|
78
|
+
if (props) {
|
|
79
|
+
out.properties = props.map(
|
|
80
|
+
(p) => `${str(p && p.namespace)}.${str(p && p.name)}=${summariseValue(p && p.value)}`,
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Log one trace line; never throws into the directive path. */
|
|
87
|
+
function traceDirective(deps, event, response, startedAt) {
|
|
88
|
+
if (!isEnabled(deps.env)) return
|
|
89
|
+
try {
|
|
90
|
+
deps.log.info('directive_trace', {
|
|
91
|
+
directive: summariseDirective(event),
|
|
92
|
+
response: summariseResponse(response),
|
|
93
|
+
ms: deps.now() - startedAt,
|
|
94
|
+
})
|
|
95
|
+
} catch {
|
|
96
|
+
// diagnostics only
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { traceDirective, summariseDirective, summariseResponse }
|
package/lambda-template/index.js
CHANGED
|
@@ -19,10 +19,13 @@
|
|
|
19
19
|
* - everything else → forward to the hub
|
|
20
20
|
*
|
|
21
21
|
* Configuration (env — none of these is a secret once D753 is deployed):
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* LWA_PARAM SSM SecureString parameter name (e.g.
|
|
23
|
+
* /camstack/alexa/lwa) holding {"clientId","clientSecret"}
|
|
24
|
+
* for the skill's "Send Alexa Events" LWA client.
|
|
25
|
+
* SKILL_PARAM SSM SecureString parameter name (e.g.
|
|
26
|
+
* /camstack/alexa/skill) holding {"clientId",
|
|
27
|
+
* "clientSecret"[,"previousClientSecret"]} for account
|
|
28
|
+
* linking.
|
|
26
29
|
* TOKENS_TABLE DynamoDB table of broker links. Unset = broker off
|
|
27
30
|
* (pre-D753 behaviour: tokens are handed to the hub).
|
|
28
31
|
* LAMBDA_REDIRECT_URI Function URL + "/token"; the AcceptGrant redirect_uri.
|
|
@@ -31,7 +34,9 @@
|
|
|
31
34
|
* instead of receiving the refresh token. Default on.
|
|
32
35
|
* ALLOW_MIGRATION "0" = refuse /broker/migrate. Default on.
|
|
33
36
|
* HUB_INGRESS_TOKEN Optional defense-in-depth header for hub calls.
|
|
34
|
-
*
|
|
37
|
+
* DEBUG_DIRECTIVES "1" = log one `directive_trace` line per directive
|
|
38
|
+
* (debug-trace.js; never the bearer). Default off.
|
|
39
|
+
* Legacy (read only when the *_PARAM is unset): LWA_CLIENT_ID,
|
|
35
40
|
* LWA_CLIENT_SECRET, SKILL_CLIENT_ID, SKILL_CLIENT_SECRET.
|
|
36
41
|
*
|
|
37
42
|
* Ships as plain files — no node_modules, no transpile (`zip` the folder's
|
|
@@ -50,6 +55,7 @@ const {
|
|
|
50
55
|
isBrokerPath,
|
|
51
56
|
shortId,
|
|
52
57
|
} = require('./broker.js')
|
|
58
|
+
const { traceDirective } = require('./debug-trace.js')
|
|
53
59
|
const { timedFetch } = require('./http.js')
|
|
54
60
|
const { createSecretSource } = require('./secrets.js')
|
|
55
61
|
const { createDynamoTokenStore } = require('./token-store.js')
|
|
@@ -101,7 +107,7 @@ function resolveDeps(overrides) {
|
|
|
101
107
|
o.secrets ||
|
|
102
108
|
createSecretSource({
|
|
103
109
|
env,
|
|
104
|
-
|
|
110
|
+
readParameter: (name) => awsClients().readParameter(name),
|
|
105
111
|
}),
|
|
106
112
|
store,
|
|
107
113
|
publicUrl: resolveBrokerPublicUrl(env),
|
|
@@ -112,23 +118,28 @@ function createHandler(overrides) {
|
|
|
112
118
|
let deps = null
|
|
113
119
|
return async (event) => {
|
|
114
120
|
if (!deps) deps = resolveDeps(overrides)
|
|
121
|
+
const startedAt = deps.now()
|
|
115
122
|
try {
|
|
116
123
|
if (event && event.requestContext && event.requestContext.http) {
|
|
117
124
|
return await handleLambdaUrl(event, deps)
|
|
118
125
|
}
|
|
119
126
|
if (event && event.directive && event.directive.header) {
|
|
120
|
-
|
|
127
|
+
const res = await handleDirective(event, deps)
|
|
128
|
+
traceDirective(deps, event, res, startedAt)
|
|
129
|
+
return res
|
|
121
130
|
}
|
|
122
131
|
return jsonResponse(400, { error: 'unknown_event' })
|
|
123
132
|
} catch (err) {
|
|
124
133
|
deps.log.error('camstack-alexa: unhandled error', describe(err))
|
|
125
134
|
if (event && event.directive && event.directive.header) {
|
|
126
|
-
|
|
135
|
+
const res = errorEnvelope(
|
|
127
136
|
event.directive.header,
|
|
128
137
|
'INTERNAL_ERROR',
|
|
129
138
|
describe(err),
|
|
130
139
|
event.directive.endpoint,
|
|
131
140
|
)
|
|
141
|
+
traceDirective(deps, event, res, startedAt)
|
|
142
|
+
return res
|
|
132
143
|
}
|
|
133
144
|
return jsonResponse(500, { error: 'internal_error', message: describe(err) })
|
|
134
145
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skill credentials, read where they live:
|
|
2
|
+
* Skill credentials, read where they live: SSM Parameter Store SecureString
|
|
3
|
+
* parameters, decrypted with the AWS-managed `aws/ssm` key (D753, D761).
|
|
3
4
|
*
|
|
4
5
|
* Two credential pairs exist, and they authenticate DIFFERENT parties:
|
|
5
6
|
*
|
|
@@ -11,30 +12,32 @@
|
|
|
11
12
|
* Account Linking page. Authenticates AMAZON to this Lambda's
|
|
12
13
|
* `/token` route. Amazon holds a copy; nothing else needs one.
|
|
13
14
|
*
|
|
14
|
-
* Each
|
|
15
|
+
* Each parameter's value is a JSON string
|
|
16
|
+
* `{"clientId": "...", "clientSecret": "..."}`.
|
|
15
17
|
* `skill` may also carry `previousClientSecret`, accepted on `/token` while a
|
|
16
18
|
* rotation is in flight (change Amazon's copy, then drop the previous one).
|
|
17
19
|
*
|
|
18
20
|
* Resolution, per pair:
|
|
19
|
-
* 1. `<KIND>
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* 1. `<KIND>_PARAM` set → that SSM parameter (read with decryption),
|
|
22
|
+
* cached for `ttlMs`.
|
|
23
|
+
* 2. otherwise → the legacy plain env vars
|
|
24
|
+
* (`LWA_CLIENT_ID`/`LWA_CLIENT_SECRET`,
|
|
25
|
+
* `SKILL_CLIENT_ID`/`SKILL_CLIENT_SECRET`), so this
|
|
26
|
+
* code can be deployed before the parameters exist
|
|
27
|
+
* and the cut-over has no downtime.
|
|
25
28
|
*
|
|
26
29
|
* The cache is what makes a rotation cheap: a new value is picked up within
|
|
27
30
|
* `ttlMs`, and immediately when LWA answers `invalid_client` (the caller calls
|
|
28
|
-
* `invalidate`). Error messages name the
|
|
31
|
+
* `invalidate`). Error messages name the parameter, never a value.
|
|
29
32
|
*/
|
|
30
33
|
'use strict'
|
|
31
34
|
|
|
32
35
|
const DEFAULT_SECRET_TTL_MS = 5 * 60 * 1000
|
|
33
36
|
|
|
34
37
|
const KINDS = {
|
|
35
|
-
lwa: {
|
|
38
|
+
lwa: { paramVar: 'LWA_PARAM', idVar: 'LWA_CLIENT_ID', secretVar: 'LWA_CLIENT_SECRET' },
|
|
36
39
|
skill: {
|
|
37
|
-
|
|
40
|
+
paramVar: 'SKILL_PARAM',
|
|
38
41
|
idVar: 'SKILL_CLIENT_ID',
|
|
39
42
|
secretVar: 'SKILL_CLIENT_SECRET',
|
|
40
43
|
},
|
|
@@ -43,7 +46,7 @@ const KINDS = {
|
|
|
43
46
|
/**
|
|
44
47
|
* @param {object} opts
|
|
45
48
|
* @param {Record<string, string|undefined>} opts.env
|
|
46
|
-
* @param {(
|
|
49
|
+
* @param {(name: string) => Promise<string>} opts.readParameter decrypted value
|
|
47
50
|
* @param {() => number} [opts.now]
|
|
48
51
|
* @param {number} [opts.ttlMs]
|
|
49
52
|
*/
|
|
@@ -55,12 +58,12 @@ function createSecretSource(opts) {
|
|
|
55
58
|
|
|
56
59
|
async function load(kind) {
|
|
57
60
|
const spec = KINDS[kind]
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
61
|
+
const paramName = env[spec.paramVar]
|
|
62
|
+
if (paramName) {
|
|
60
63
|
const hit = cache.get(kind)
|
|
61
64
|
if (hit && now() - hit.at < ttlMs) return hit.value
|
|
62
|
-
const raw = await opts.
|
|
63
|
-
const value =
|
|
65
|
+
const raw = await opts.readParameter(paramName)
|
|
66
|
+
const value = parseCredentialParameter(raw, paramName)
|
|
64
67
|
cache.set(kind, { value, at: now() })
|
|
65
68
|
return value
|
|
66
69
|
}
|
|
@@ -79,12 +82,12 @@ function createSecretSource(opts) {
|
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
82
|
-
function
|
|
85
|
+
function parseCredentialParameter(raw, paramName) {
|
|
83
86
|
let obj
|
|
84
87
|
try {
|
|
85
88
|
obj = JSON.parse(raw)
|
|
86
89
|
} catch {
|
|
87
|
-
throw new Error(`
|
|
90
|
+
throw new Error(`parameter ${paramName} is not a JSON object`)
|
|
88
91
|
}
|
|
89
92
|
if (
|
|
90
93
|
!obj ||
|
|
@@ -93,13 +96,13 @@ function parseCredentialSecret(raw, secretId) {
|
|
|
93
96
|
typeof obj.clientSecret !== 'string' ||
|
|
94
97
|
obj.clientSecret.length === 0
|
|
95
98
|
) {
|
|
96
|
-
throw new Error(`
|
|
99
|
+
throw new Error(`parameter ${paramName} must be {"clientId": "...", "clientSecret": "..."}`)
|
|
97
100
|
}
|
|
98
|
-
const out = { clientId: obj.clientId, clientSecret: obj.clientSecret, source: '
|
|
101
|
+
const out = { clientId: obj.clientId, clientSecret: obj.clientSecret, source: 'ssm' }
|
|
99
102
|
if (typeof obj.previousClientSecret === 'string' && obj.previousClientSecret.length > 0) {
|
|
100
103
|
out.previousClientSecret = obj.previousClientSecret
|
|
101
104
|
}
|
|
102
105
|
return out
|
|
103
106
|
}
|
|
104
107
|
|
|
105
|
-
module.exports = { createSecretSource,
|
|
108
|
+
module.exports = { createSecretSource, parseCredentialParameter, DEFAULT_SECRET_TTL_MS }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-export-alexa",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.136",
|
|
4
4
|
"description": "Alexa Smart Home Skill export — hub-side OAuth provider + directive handler. The companion AWS Lambda forwards Alexa directives to this addon's /addon/export-alexa/directive endpoint.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|