@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.
- package/dist/export-alexa.addon.js +668 -297
- package/dist/export-alexa.addon.mjs +668 -297
- package/lambda-template/README.md +154 -99
- 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 +259 -372
- package/lambda-template/secrets.js +105 -0
- package/lambda-template/token-store.js +142 -0
- package/lambda-template/util.js +128 -0
- package/package.json +1 -1
|
@@ -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,128 @@
|
|
|
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
|
+
function errorEnvelope(header, type, message) {
|
|
98
|
+
return {
|
|
99
|
+
event: {
|
|
100
|
+
header: {
|
|
101
|
+
namespace: 'Alexa',
|
|
102
|
+
name: 'ErrorResponse',
|
|
103
|
+
messageId: header.messageId,
|
|
104
|
+
payloadVersion: '3',
|
|
105
|
+
},
|
|
106
|
+
payload: { type, message },
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function describe(err) {
|
|
112
|
+
if (err instanceof Error) return err.message
|
|
113
|
+
return String(err)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
decodeJwtPayload,
|
|
118
|
+
normaliseHubUrl,
|
|
119
|
+
decodeBody,
|
|
120
|
+
parseFormUrlEncoded,
|
|
121
|
+
encodeFormUrlEncoded,
|
|
122
|
+
parseJsonBody,
|
|
123
|
+
readBearer,
|
|
124
|
+
resolveBrokerPublicUrl,
|
|
125
|
+
jsonResponse,
|
|
126
|
+
errorEnvelope,
|
|
127
|
+
describe,
|
|
128
|
+
}
|
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.133",
|
|
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",
|