@camstack/addon-export-alexa 1.2.133 → 1.2.135
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 +236 -455
- package/dist/export-alexa.addon.mjs +237 -455
- package/lambda-template/README.md +57 -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 +112 -37
- package/lambda-template/secrets.js +24 -21
- package/lambda-template/util.js +54 -1
- package/package.json +1 -1
|
@@ -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/lambda-template/util.js
CHANGED
|
@@ -94,20 +94,71 @@ function jsonResponse(statusCode, body) {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
/**
|
|
98
|
+
* `Alexa.ErrorResponse` for a directive. Amazon requires the directive's
|
|
99
|
+
* `correlationToken` and the target `endpoint` on it (alexa-errorresponse);
|
|
100
|
+
* both are copied when the directive carried them.
|
|
101
|
+
*/
|
|
102
|
+
function errorEnvelope(header, type, message, endpoint) {
|
|
103
|
+
const endpointId =
|
|
104
|
+
endpoint && typeof endpoint.endpointId === 'string' ? endpoint.endpointId : null
|
|
98
105
|
return {
|
|
99
106
|
event: {
|
|
100
107
|
header: {
|
|
101
108
|
namespace: 'Alexa',
|
|
102
109
|
name: 'ErrorResponse',
|
|
103
110
|
messageId: header.messageId,
|
|
111
|
+
...(typeof header.correlationToken === 'string'
|
|
112
|
+
? { correlationToken: header.correlationToken }
|
|
113
|
+
: {}),
|
|
104
114
|
payloadVersion: '3',
|
|
105
115
|
},
|
|
116
|
+
...(endpointId !== null ? { endpoint: { endpointId } } : {}),
|
|
106
117
|
payload: { type, message },
|
|
107
118
|
},
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
/** AcceptGrant's own error: `Alexa.Authorization/ErrorResponse` (alexa-authorization). */
|
|
123
|
+
function acceptGrantErrorEnvelope(header, message) {
|
|
124
|
+
return {
|
|
125
|
+
event: {
|
|
126
|
+
header: {
|
|
127
|
+
namespace: 'Alexa.Authorization',
|
|
128
|
+
name: 'ErrorResponse',
|
|
129
|
+
messageId: header.messageId,
|
|
130
|
+
payloadVersion: '3',
|
|
131
|
+
},
|
|
132
|
+
payload: { type: 'ACCEPT_GRANT_FAILED', message },
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fill in what an `Alexa.ErrorResponse` from a hub may lack — a hub running an
|
|
139
|
+
* older addon omits `correlationToken` and `endpoint`. Any other body is
|
|
140
|
+
* returned as it is.
|
|
141
|
+
*/
|
|
142
|
+
function completeErrorResponse(body, directive) {
|
|
143
|
+
const ev = body && body.event
|
|
144
|
+
if (!ev || !ev.header || ev.header.name !== 'ErrorResponse' || ev.header.namespace !== 'Alexa') {
|
|
145
|
+
return body
|
|
146
|
+
}
|
|
147
|
+
const header = { ...ev.header }
|
|
148
|
+
if (
|
|
149
|
+
header.correlationToken === undefined &&
|
|
150
|
+
typeof directive.header.correlationToken === 'string'
|
|
151
|
+
) {
|
|
152
|
+
header.correlationToken = directive.header.correlationToken
|
|
153
|
+
}
|
|
154
|
+
const endpointId =
|
|
155
|
+
directive.endpoint && typeof directive.endpoint.endpointId === 'string'
|
|
156
|
+
? directive.endpoint.endpointId
|
|
157
|
+
: null
|
|
158
|
+
const endpoint = ev.endpoint || (endpointId !== null ? { endpointId } : undefined)
|
|
159
|
+
return { ...body, event: { ...ev, header, ...(endpoint ? { endpoint } : {}) } }
|
|
160
|
+
}
|
|
161
|
+
|
|
111
162
|
function describe(err) {
|
|
112
163
|
if (err instanceof Error) return err.message
|
|
113
164
|
return String(err)
|
|
@@ -124,5 +175,7 @@ module.exports = {
|
|
|
124
175
|
resolveBrokerPublicUrl,
|
|
125
176
|
jsonResponse,
|
|
126
177
|
errorEnvelope,
|
|
178
|
+
acceptGrantErrorEnvelope,
|
|
179
|
+
completeErrorResponse,
|
|
127
180
|
describe,
|
|
128
181
|
}
|
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.135",
|
|
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",
|