@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.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Skill credentials, read where they live: AWS Secrets Manager (D753).
3
+ *
4
+ * Two credential pairs exist, and they authenticate DIFFERENT parties:
5
+ *
6
+ * lwa The skill's "Send Alexa Events" (Alexa Skill Messaging) LWA client.
7
+ * Authenticates THIS Lambda to api.amazon.com when it exchanges an
8
+ * AcceptGrant code and when it refreshes a user's grant. It is the
9
+ * one that must never leave the Lambda.
10
+ * skill The account-linking client id/secret typed into the skill's
11
+ * Account Linking page. Authenticates AMAZON to this Lambda's
12
+ * `/token` route. Amazon holds a copy; nothing else needs one.
13
+ *
14
+ * Each secret is a JSON string `{"clientId": "...", "clientSecret": "..."}`.
15
+ * `skill` may also carry `previousClientSecret`, accepted on `/token` while a
16
+ * rotation is in flight (change Amazon's copy, then drop the previous one).
17
+ *
18
+ * Resolution, per pair:
19
+ * 1. `<KIND>_SECRET_ID` set → Secrets Manager, cached for `ttlMs`.
20
+ * 2. otherwise → the legacy plain env vars
21
+ * (`LWA_CLIENT_ID`/`LWA_CLIENT_SECRET`,
22
+ * `SKILL_CLIENT_ID`/`SKILL_CLIENT_SECRET`), so
23
+ * this code can be deployed before the secrets
24
+ * exist and the cut-over has no downtime.
25
+ *
26
+ * The cache is what makes a rotation cheap: a new value is picked up within
27
+ * `ttlMs`, and immediately when LWA answers `invalid_client` (the caller calls
28
+ * `invalidate`). Error messages name the secret id, never a value.
29
+ */
30
+ 'use strict'
31
+
32
+ const DEFAULT_SECRET_TTL_MS = 5 * 60 * 1000
33
+
34
+ const KINDS = {
35
+ lwa: { secretIdVar: 'LWA_SECRET_ID', idVar: 'LWA_CLIENT_ID', secretVar: 'LWA_CLIENT_SECRET' },
36
+ skill: {
37
+ secretIdVar: 'SKILL_SECRET_ID',
38
+ idVar: 'SKILL_CLIENT_ID',
39
+ secretVar: 'SKILL_CLIENT_SECRET',
40
+ },
41
+ }
42
+
43
+ /**
44
+ * @param {object} opts
45
+ * @param {Record<string, string|undefined>} opts.env
46
+ * @param {(secretId: string) => Promise<string>} opts.fetchSecretString
47
+ * @param {() => number} [opts.now]
48
+ * @param {number} [opts.ttlMs]
49
+ */
50
+ function createSecretSource(opts) {
51
+ const env = opts.env
52
+ const now = opts.now || Date.now
53
+ const ttlMs = typeof opts.ttlMs === 'number' ? opts.ttlMs : DEFAULT_SECRET_TTL_MS
54
+ const cache = new Map()
55
+
56
+ async function load(kind) {
57
+ const spec = KINDS[kind]
58
+ const secretId = env[spec.secretIdVar]
59
+ if (secretId) {
60
+ const hit = cache.get(kind)
61
+ if (hit && now() - hit.at < ttlMs) return hit.value
62
+ const raw = await opts.fetchSecretString(secretId)
63
+ const value = parseCredentialSecret(raw, secretId)
64
+ cache.set(kind, { value, at: now() })
65
+ return value
66
+ }
67
+ const clientId = env[spec.idVar]
68
+ const clientSecret = env[spec.secretVar]
69
+ if (clientId && clientSecret) return { clientId, clientSecret, source: 'env' }
70
+ return null
71
+ }
72
+
73
+ return {
74
+ lwa: () => load('lwa'),
75
+ skill: () => load('skill'),
76
+ invalidate: (kind) => {
77
+ cache.delete(kind)
78
+ },
79
+ }
80
+ }
81
+
82
+ function parseCredentialSecret(raw, secretId) {
83
+ let obj
84
+ try {
85
+ obj = JSON.parse(raw)
86
+ } catch {
87
+ throw new Error(`secret ${secretId} is not a JSON object`)
88
+ }
89
+ if (
90
+ !obj ||
91
+ typeof obj.clientId !== 'string' ||
92
+ obj.clientId.length === 0 ||
93
+ typeof obj.clientSecret !== 'string' ||
94
+ obj.clientSecret.length === 0
95
+ ) {
96
+ throw new Error(`secret ${secretId} must be {"clientId": "...", "clientSecret": "..."}`)
97
+ }
98
+ const out = { clientId: obj.clientId, clientSecret: obj.clientSecret, source: 'secrets-manager' }
99
+ if (typeof obj.previousClientSecret === 'string' && obj.previousClientSecret.length > 0) {
100
+ out.previousClientSecret = obj.previousClientSecret
101
+ }
102
+ return out
103
+ }
104
+
105
+ module.exports = { createSecretSource, parseCredentialSecret, DEFAULT_SECRET_TTL_MS }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Broker link store — one DynamoDB item per hub link (D753).
3
+ *
4
+ * Table `camstack-alexa-tokens` (name from `TOKENS_TABLE`), partition key
5
+ * `linkId` (S), TTL attribute `ttl` (epoch SECONDS), encrypted at rest (SSE,
6
+ * KMS). An item is the Lambda's half of one hub's Alexa push grant:
7
+ *
8
+ * linkId random 128-bit id — the public half of the hub credential
9
+ * secretHash sha256(hex) of the credential's secret half; the secret
10
+ * itself is never stored
11
+ * status 'pending' (AcceptGrant in flight) | 'active'
12
+ * refreshToken the user's LWA refresh token — NEVER leaves the Lambda
13
+ * accessToken cached LWA access token (handed to the hub on request)
14
+ * accessExpiresAt ms epoch
15
+ * hubUrl informational: the hub the link was made for
16
+ * origin 'accept-grant' | 'migration'
17
+ * createdAt, lastUsedAt ms epoch
18
+ * ttl s epoch; pending links expire in 1h, active ones after
19
+ * an idle period (bumped on every refresh)
20
+ * version optimistic-concurrency counter; every write is
21
+ * conditional on it, so two concurrent refreshes never
22
+ * clobber each other's rotated refresh token
23
+ *
24
+ * The store is an interface (`get`/`create`/`replace`/`remove`) so the broker
25
+ * is tested against an in-memory implementation and the DynamoDB marshalling
26
+ * is tested on its own.
27
+ */
28
+ 'use strict'
29
+
30
+ const STRING_FIELDS = [
31
+ 'linkId',
32
+ 'secretHash',
33
+ 'status',
34
+ 'refreshToken',
35
+ 'accessToken',
36
+ 'hubUrl',
37
+ 'origin',
38
+ ]
39
+ const NUMBER_FIELDS = ['accessExpiresAt', 'createdAt', 'lastUsedAt', 'ttl', 'version']
40
+
41
+ function marshal(item) {
42
+ const out = {}
43
+ for (const k of STRING_FIELDS) {
44
+ if (typeof item[k] === 'string') out[k] = { S: item[k] }
45
+ }
46
+ for (const k of NUMBER_FIELDS) {
47
+ if (typeof item[k] === 'number' && Number.isFinite(item[k])) out[k] = { N: String(item[k]) }
48
+ }
49
+ return out
50
+ }
51
+
52
+ function unmarshal(raw) {
53
+ if (!raw) return null
54
+ const out = {}
55
+ for (const k of STRING_FIELDS) {
56
+ if (raw[k] && typeof raw[k].S === 'string') out[k] = raw[k].S
57
+ }
58
+ for (const k of NUMBER_FIELDS) {
59
+ if (raw[k] && typeof raw[k].N === 'string') out[k] = Number(raw[k].N)
60
+ }
61
+ return out
62
+ }
63
+
64
+ function isConditionalFailure(err) {
65
+ return Boolean(err) && err.name === 'ConditionalCheckFailedException'
66
+ }
67
+
68
+ /**
69
+ * @param {object} opts
70
+ * @param {(command: 'GetItem'|'PutItem'|'DeleteItem', input: object) => Promise<any>} opts.send
71
+ * @param {string} opts.tableName
72
+ */
73
+ function createDynamoTokenStore(opts) {
74
+ const TableName = opts.tableName
75
+ const send = opts.send
76
+ return {
77
+ async get(linkId) {
78
+ const res = await send('GetItem', {
79
+ TableName,
80
+ Key: { linkId: { S: linkId } },
81
+ ConsistentRead: true,
82
+ })
83
+ return unmarshal(res && res.Item)
84
+ },
85
+ async create(item) {
86
+ await send('PutItem', {
87
+ TableName,
88
+ Item: marshal(item),
89
+ ConditionExpression: 'attribute_not_exists(linkId)',
90
+ })
91
+ },
92
+ /** Replace the whole item iff its stored version is `expectedVersion`. */
93
+ async replace(item, expectedVersion) {
94
+ try {
95
+ await send('PutItem', {
96
+ TableName,
97
+ Item: marshal(item),
98
+ ConditionExpression: 'version = :v',
99
+ ExpressionAttributeValues: { ':v': { N: String(expectedVersion) } },
100
+ })
101
+ return true
102
+ } catch (err) {
103
+ if (isConditionalFailure(err)) return false
104
+ throw err
105
+ }
106
+ },
107
+ async remove(linkId) {
108
+ await send('DeleteItem', { TableName, Key: { linkId: { S: linkId } } })
109
+ },
110
+ }
111
+ }
112
+
113
+ /** In-memory store with the same contract — tests, and local runs. */
114
+ function createMemoryTokenStore() {
115
+ const items = new Map()
116
+ return {
117
+ items,
118
+ async get(linkId) {
119
+ const it = items.get(linkId)
120
+ return it ? { ...it } : null
121
+ },
122
+ async create(item) {
123
+ if (items.has(item.linkId)) {
124
+ const err = new Error('exists')
125
+ err.name = 'ConditionalCheckFailedException'
126
+ throw err
127
+ }
128
+ items.set(item.linkId, { ...item })
129
+ },
130
+ async replace(item, expectedVersion) {
131
+ const cur = items.get(item.linkId)
132
+ if (!cur || cur.version !== expectedVersion) return false
133
+ items.set(item.linkId, { ...item })
134
+ return true
135
+ },
136
+ async remove(linkId) {
137
+ items.delete(linkId)
138
+ },
139
+ }
140
+ }
141
+
142
+ module.exports = { createDynamoTokenStore, createMemoryTokenStore, marshal, unmarshal }
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Small pure helpers shared by the Lambda's modules. No I/O here.
3
+ */
4
+ 'use strict'
5
+
6
+ /** Base64url-decode a JWT's payload WITHOUT verifying it — routing only. */
7
+ function decodeJwtPayload(token) {
8
+ if (typeof token !== 'string') return null
9
+ const parts = token.split('.')
10
+ if (parts.length < 2) return null
11
+ try {
12
+ const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/')
13
+ const json = Buffer.from(padded, 'base64').toString('utf8')
14
+ return JSON.parse(json)
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ function normaliseHubUrl(raw) {
21
+ return raw.replace(/\/+$/, '')
22
+ }
23
+
24
+ function decodeBody(event) {
25
+ if (!event.body) return ''
26
+ if (event.isBase64Encoded) {
27
+ return Buffer.from(event.body, 'base64').toString('utf8')
28
+ }
29
+ return event.body
30
+ }
31
+
32
+ function parseFormUrlEncoded(body) {
33
+ const out = {}
34
+ if (!body) return out
35
+ for (const pair of body.split('&')) {
36
+ if (!pair) continue
37
+ const eq = pair.indexOf('=')
38
+ const key = decodeURIComponent(eq >= 0 ? pair.slice(0, eq) : pair).replace(/\+/g, ' ')
39
+ const value = decodeURIComponent(eq >= 0 ? pair.slice(eq + 1) : '').replace(/\+/g, ' ')
40
+ out[key] = value
41
+ }
42
+ return out
43
+ }
44
+
45
+ function encodeFormUrlEncoded(obj) {
46
+ return Object.keys(obj)
47
+ .filter((k) => obj[k] !== undefined && obj[k] !== null)
48
+ .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(obj[k]))}`)
49
+ .join('&')
50
+ }
51
+
52
+ /** JSON body of a Function URL request, or null when absent/unparseable. */
53
+ function parseJsonBody(event) {
54
+ const raw = decodeBody(event)
55
+ if (!raw) return null
56
+ try {
57
+ const parsed = JSON.parse(raw)
58
+ return parsed && typeof parsed === 'object' ? parsed : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /** `Authorization: Bearer <x>` from a Function URL event (headers are lower-cased). */
65
+ function readBearer(event) {
66
+ const headers = event.headers || {}
67
+ const value = headers.authorization || headers.Authorization
68
+ if (typeof value !== 'string') return null
69
+ const m = /^bearer\s+(.+)$/i.exec(value.trim())
70
+ return m ? m[1].trim() : null
71
+ }
72
+
73
+ /**
74
+ * The broker's public origin, as hubs must call it. `BROKER_PUBLIC_URL` when
75
+ * set, else the origin of `LAMBDA_REDIRECT_URI` (which is the Function URL +
76
+ * `/token`). HTTPS only — a hub refuses anything else.
77
+ */
78
+ function resolveBrokerPublicUrl(env) {
79
+ const raw = env.BROKER_PUBLIC_URL || env.LAMBDA_REDIRECT_URI
80
+ if (!raw) return null
81
+ try {
82
+ const u = new URL(raw)
83
+ return u.protocol === 'https:' ? u.origin : null
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ function jsonResponse(statusCode, body) {
90
+ return {
91
+ statusCode,
92
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
93
+ body: JSON.stringify(body),
94
+ }
95
+ }
96
+
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
105
+ return {
106
+ event: {
107
+ header: {
108
+ namespace: 'Alexa',
109
+ name: 'ErrorResponse',
110
+ messageId: header.messageId,
111
+ ...(typeof header.correlationToken === 'string'
112
+ ? { correlationToken: header.correlationToken }
113
+ : {}),
114
+ payloadVersion: '3',
115
+ },
116
+ ...(endpointId !== null ? { endpoint: { endpointId } } : {}),
117
+ payload: { type, message },
118
+ },
119
+ }
120
+ }
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
+
162
+ function describe(err) {
163
+ if (err instanceof Error) return err.message
164
+ return String(err)
165
+ }
166
+
167
+ module.exports = {
168
+ decodeJwtPayload,
169
+ normaliseHubUrl,
170
+ decodeBody,
171
+ parseFormUrlEncoded,
172
+ encodeFormUrlEncoded,
173
+ parseJsonBody,
174
+ readBearer,
175
+ resolveBrokerPublicUrl,
176
+ jsonResponse,
177
+ errorEnvelope,
178
+ acceptGrantErrorEnvelope,
179
+ completeErrorResponse,
180
+ describe,
181
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-alexa",
3
- "version": "1.2.132",
3
+ "version": "1.2.134",
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",