@camstack/addon-export-alexa 1.2.131 → 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 +171 -0
- package/lambda-template/aws.js +37 -0
- package/lambda-template/broker.js +382 -0
- package/lambda-template/http.js +174 -0
- package/lambda-template/icons/alexa-skill-icon-108.png +0 -0
- package/lambda-template/icons/alexa-skill-icon-512.png +0 -0
- package/lambda-template/index.js +454 -0
- 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 +3 -2
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# CamStack Alexa Lambda — skill endpoint + token broker
|
|
2
|
+
|
|
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
|
+
|
|
9
|
+
The same code runs the built-in CamStack skill and any operator's own
|
|
10
|
+
("custom") skill. Each deployment uses its own secrets.
|
|
11
|
+
|
|
12
|
+
## What it does
|
|
13
|
+
|
|
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). |
|
|
24
|
+
|
|
25
|
+
### The two credential pairs
|
|
26
|
+
|
|
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 |
|
|
31
|
+
|
|
32
|
+
Each secret is a JSON string:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{ "clientId": "…", "clientSecret": "…" }
|
|
36
|
+
```
|
|
37
|
+
|
|
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).
|
|
41
|
+
|
|
42
|
+
### The broker credential
|
|
43
|
+
|
|
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).
|
|
49
|
+
|
|
50
|
+
## Files
|
|
51
|
+
|
|
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:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd lambda-template && rm -f ../camstack-alexa-lambda.zip && zip -j ../camstack-alexa-lambda.zip *.js
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Deployment
|
|
61
|
+
|
|
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`.
|
|
65
|
+
|
|
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 }
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Alexa token broker (D753).
|
|
3
|
+
*
|
|
4
|
+
* The skill's LWA client secret authenticates every grant exchange and every
|
|
5
|
+
* refresh at api.amazon.com. Before D753 a copy of it lived in every hub — in
|
|
6
|
+
* public source and in a public npm package. Now it lives ONLY here (read from
|
|
7
|
+
* Secrets Manager), together with each user's LWA refresh token, and a hub
|
|
8
|
+
* gets exactly what it needs to push events: a short-lived access token.
|
|
9
|
+
*
|
|
10
|
+
* A hub authenticates to the broker with a PER-LINK credential
|
|
11
|
+
* `csb1.<linkId>.<secret>` minted at link time:
|
|
12
|
+
* - no shared secret: each link has its own, so one leaked hub exposes one
|
|
13
|
+
* link, never the skill;
|
|
14
|
+
* - only sha256(secret) is stored, compared in constant time;
|
|
15
|
+
* - revocable: delete the item (`/broker/revoke`, or the item's TTL lapses);
|
|
16
|
+
* - rotatable: `/broker/rotate` swaps the secret atomically, and every
|
|
17
|
+
* re-link mints a fresh one (the old item expires on its idle TTL).
|
|
18
|
+
*
|
|
19
|
+
* HTTP routes (Function URL, POST only):
|
|
20
|
+
* /broker/token Bearer <credential> → { accessToken, expiresAt }
|
|
21
|
+
* /broker/migrate { refreshToken } → { brokerCredential, brokerUrl, accessToken, expiresAt }
|
|
22
|
+
* /broker/rotate Bearer <credential> → { brokerCredential }
|
|
23
|
+
* /broker/revoke Bearer <credential> → { ok: true }
|
|
24
|
+
*
|
|
25
|
+
* `/broker/migrate` is the zero-touch path for a hub linked before D753: it
|
|
26
|
+
* still holds a refresh token (and, until it updates, the leaked secret). The
|
|
27
|
+
* broker proves the token is a live grant OF THIS SKILL by refreshing it with
|
|
28
|
+
* the skill's secret — possession of that token is already the full power of
|
|
29
|
+
* the grant, so the broker hands back nothing the caller did not have. The hub
|
|
30
|
+
* then deletes its copy. Disable with `ALLOW_MIGRATION=0` once every hub moved.
|
|
31
|
+
*
|
|
32
|
+
* Nothing here logs a token, a code, a credential or a secret.
|
|
33
|
+
*/
|
|
34
|
+
'use strict'
|
|
35
|
+
|
|
36
|
+
const crypto = require('node:crypto')
|
|
37
|
+
const { encodeFormUrlEncoded, jsonResponse, parseJsonBody, readBearer } = require('./util.js')
|
|
38
|
+
|
|
39
|
+
const LWA_TOKEN_URL = 'https://api.amazon.com/auth/o2/token'
|
|
40
|
+
const CREDENTIAL_PREFIX = 'csb1'
|
|
41
|
+
/** Hand out a cached access token only while it has at least this much life left. */
|
|
42
|
+
const ACCESS_TOKEN_MIN_LIFE_MS = 5 * 60 * 1000
|
|
43
|
+
/** A pending (mid-AcceptGrant) item that never activated is gone after this. */
|
|
44
|
+
const PENDING_TTL_SEC = 60 * 60
|
|
45
|
+
/** An active link nobody refreshed for this long is purged by DynamoDB TTL. */
|
|
46
|
+
const IDLE_TTL_SEC = 180 * 24 * 60 * 60
|
|
47
|
+
|
|
48
|
+
class BrokerError extends Error {
|
|
49
|
+
constructor(status, code, message) {
|
|
50
|
+
super(message)
|
|
51
|
+
this.status = status
|
|
52
|
+
this.code = code
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Credentials ──────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function sha256Hex(value) {
|
|
59
|
+
return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {(n: number) => Buffer} randomBytes */
|
|
63
|
+
function mintCredential(randomBytes, linkId) {
|
|
64
|
+
const id = linkId || randomBytes(16).toString('base64url')
|
|
65
|
+
const secret = randomBytes(32).toString('base64url')
|
|
66
|
+
return {
|
|
67
|
+
linkId: id,
|
|
68
|
+
secretHash: sha256Hex(secret),
|
|
69
|
+
credential: `${CREDENTIAL_PREFIX}.${id}.${secret}`,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
74
|
+
|
|
75
|
+
function parseCredential(raw) {
|
|
76
|
+
if (typeof raw !== 'string') return null
|
|
77
|
+
const parts = raw.split('.')
|
|
78
|
+
if (parts.length !== 3 || parts[0] !== CREDENTIAL_PREFIX) return null
|
|
79
|
+
const [, linkId, secret] = parts
|
|
80
|
+
if (!B64URL.test(linkId) || !B64URL.test(secret)) return null
|
|
81
|
+
if (linkId.length < 16 || linkId.length > 64 || secret.length < 32 || secret.length > 128) {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
return { linkId, secret }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function secretMatches(secret, storedHash) {
|
|
88
|
+
if (typeof storedHash !== 'string' || storedHash.length !== 64) return false
|
|
89
|
+
const a = Buffer.from(sha256Hex(secret), 'hex')
|
|
90
|
+
const b = Buffer.from(storedHash, 'hex')
|
|
91
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── LWA ──────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
async function postLwa(deps, params, creds) {
|
|
97
|
+
const resp = await deps.fetch(LWA_TOKEN_URL, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
100
|
+
body: encodeFormUrlEncoded({
|
|
101
|
+
...params,
|
|
102
|
+
client_id: creds.clientId,
|
|
103
|
+
client_secret: creds.clientSecret,
|
|
104
|
+
}),
|
|
105
|
+
})
|
|
106
|
+
const text = await resp.text().catch(() => '')
|
|
107
|
+
let body = null
|
|
108
|
+
try {
|
|
109
|
+
body = JSON.parse(text)
|
|
110
|
+
} catch {
|
|
111
|
+
body = null
|
|
112
|
+
}
|
|
113
|
+
return { ok: resp.ok, status: resp.status, body }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function lwaErrorCode(res) {
|
|
117
|
+
return res.body && typeof res.body.error === 'string' ? res.body.error : null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* One call to the LWA token endpoint with the skill's credentials. On
|
|
122
|
+
* `invalid_client` with a Secrets-Manager-sourced secret, drops the cache and
|
|
123
|
+
* retries once with a freshly read value — so a rotation takes effect on the
|
|
124
|
+
* very next call instead of after the cache TTL.
|
|
125
|
+
*/
|
|
126
|
+
async function lwaTokenCall(deps, params) {
|
|
127
|
+
const creds = await deps.secrets.lwa()
|
|
128
|
+
if (!creds) {
|
|
129
|
+
throw new BrokerError(500, 'unconfigured', 'LWA client credentials are not configured')
|
|
130
|
+
}
|
|
131
|
+
let res = await postLwa(deps, params, creds)
|
|
132
|
+
if (lwaErrorCode(res) === 'invalid_client' && creds.source === 'secrets-manager') {
|
|
133
|
+
deps.secrets.invalidate('lwa')
|
|
134
|
+
const fresh = await deps.secrets.lwa()
|
|
135
|
+
if (fresh && fresh.clientSecret !== creds.clientSecret) {
|
|
136
|
+
deps.log.warn('broker: LWA rejected the cached client secret; retried with a fresh read')
|
|
137
|
+
res = await postLwa(deps, params, fresh)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return res
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readLwaTokens(res, fallbackRefreshToken) {
|
|
144
|
+
const b = res.body || {}
|
|
145
|
+
if (typeof b.access_token !== 'string' || b.access_token.length === 0) return null
|
|
146
|
+
const expiresIn = typeof b.expires_in === 'number' && b.expires_in > 0 ? b.expires_in : 3600
|
|
147
|
+
const refreshToken =
|
|
148
|
+
typeof b.refresh_token === 'string' && b.refresh_token.length > 0
|
|
149
|
+
? b.refresh_token
|
|
150
|
+
: fallbackRefreshToken
|
|
151
|
+
if (!refreshToken) return null
|
|
152
|
+
return { accessToken: b.access_token, refreshToken, expiresInSec: expiresIn }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Exchange an AcceptGrant code. Throws BrokerError on any failure. */
|
|
156
|
+
async function exchangeGrantCode(deps, code, redirectUri) {
|
|
157
|
+
const res = await lwaTokenCall(deps, {
|
|
158
|
+
grant_type: 'authorization_code',
|
|
159
|
+
code,
|
|
160
|
+
redirect_uri: redirectUri,
|
|
161
|
+
})
|
|
162
|
+
const tokens = res.ok ? readLwaTokens(res, null) : null
|
|
163
|
+
if (!tokens) {
|
|
164
|
+
throw new BrokerError(502, 'lwa_exchange_failed', `LWA exchange ${res.status} ${lwaErrorCode(res) || ''}`.trim())
|
|
165
|
+
}
|
|
166
|
+
return tokens
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Refresh a grant. `invalid_grant` → 410 (the user disabled the skill / Amazon revoked it). */
|
|
170
|
+
async function refreshGrant(deps, refreshToken) {
|
|
171
|
+
const res = await lwaTokenCall(deps, { grant_type: 'refresh_token', refresh_token: refreshToken })
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
if (lwaErrorCode(res) === 'invalid_grant') {
|
|
174
|
+
throw new BrokerError(410, 'grant_revoked', 'Amazon no longer honours this grant; re-link the skill')
|
|
175
|
+
}
|
|
176
|
+
throw new BrokerError(502, 'lwa_unavailable', `LWA refresh ${res.status} ${lwaErrorCode(res) || ''}`.trim())
|
|
177
|
+
}
|
|
178
|
+
const tokens = readLwaTokens(res, refreshToken)
|
|
179
|
+
if (!tokens) throw new BrokerError(502, 'lwa_malformed', 'LWA refresh answered without an access token')
|
|
180
|
+
return tokens
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Link lifecycle ───────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function nowSec(deps) {
|
|
186
|
+
return Math.floor(deps.now() / 1000)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create a link item for freshly obtained tokens. `status` is `pending` for an
|
|
191
|
+
* AcceptGrant (activated only after the hub accepted the credential) and
|
|
192
|
+
* `active` for a migration (the caller already proved possession).
|
|
193
|
+
*/
|
|
194
|
+
async function createLink(deps, opts) {
|
|
195
|
+
const minted = mintCredential(deps.randomBytes)
|
|
196
|
+
const now = deps.now()
|
|
197
|
+
const item = {
|
|
198
|
+
linkId: minted.linkId,
|
|
199
|
+
secretHash: minted.secretHash,
|
|
200
|
+
status: opts.status,
|
|
201
|
+
refreshToken: opts.tokens.refreshToken,
|
|
202
|
+
accessToken: opts.tokens.accessToken,
|
|
203
|
+
accessExpiresAt: now + opts.tokens.expiresInSec * 1000,
|
|
204
|
+
...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}),
|
|
205
|
+
origin: opts.origin,
|
|
206
|
+
createdAt: now,
|
|
207
|
+
lastUsedAt: now,
|
|
208
|
+
ttl: nowSec(deps) + (opts.status === 'pending' ? PENDING_TTL_SEC : IDLE_TTL_SEC),
|
|
209
|
+
version: 1,
|
|
210
|
+
}
|
|
211
|
+
await deps.store.create(item)
|
|
212
|
+
return { item, credential: minted.credential }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function activateLink(deps, item) {
|
|
216
|
+
const next = { ...item, status: 'active', ttl: nowSec(deps) + IDLE_TTL_SEC, version: item.version + 1 }
|
|
217
|
+
const ok = await deps.store.replace(next, item.version)
|
|
218
|
+
if (!ok) throw new BrokerError(409, 'conflict', 'link changed while activating')
|
|
219
|
+
return next
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Resolve the request's credential to an ACTIVE item, or throw 401. */
|
|
223
|
+
async function authenticate(deps, event) {
|
|
224
|
+
const parsed = parseCredential(readBearer(event))
|
|
225
|
+
if (!parsed) throw new BrokerError(401, 'invalid_credential', 'broker credential missing or malformed')
|
|
226
|
+
const item = await deps.store.get(parsed.linkId)
|
|
227
|
+
// One answer for "no such link", "not active" and "wrong secret" — the
|
|
228
|
+
// response must not tell a prober which linkIds exist.
|
|
229
|
+
if (!item || item.status !== 'active' || !secretMatches(parsed.secret, item.secretHash)) {
|
|
230
|
+
throw new BrokerError(401, 'invalid_credential', 'broker credential not recognised')
|
|
231
|
+
}
|
|
232
|
+
return item
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A valid access token for the item, refreshing (and persisting) when needed. */
|
|
236
|
+
async function currentAccessToken(deps, item) {
|
|
237
|
+
const now = deps.now()
|
|
238
|
+
if (item.accessToken && item.accessExpiresAt - now > ACCESS_TOKEN_MIN_LIFE_MS) return item
|
|
239
|
+
let tokens
|
|
240
|
+
try {
|
|
241
|
+
tokens = await refreshGrant(deps, item.refreshToken)
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (err instanceof BrokerError && err.code === 'grant_revoked') {
|
|
244
|
+
await deps.store.remove(item.linkId)
|
|
245
|
+
deps.log.warn('broker: grant revoked by Amazon; link removed', { link: shortId(item.linkId) })
|
|
246
|
+
}
|
|
247
|
+
throw err
|
|
248
|
+
}
|
|
249
|
+
const next = {
|
|
250
|
+
...item,
|
|
251
|
+
accessToken: tokens.accessToken,
|
|
252
|
+
refreshToken: tokens.refreshToken,
|
|
253
|
+
accessExpiresAt: now + tokens.expiresInSec * 1000,
|
|
254
|
+
lastUsedAt: now,
|
|
255
|
+
ttl: nowSec(deps) + IDLE_TTL_SEC,
|
|
256
|
+
version: item.version + 1,
|
|
257
|
+
}
|
|
258
|
+
if (await deps.store.replace(next, item.version)) return next
|
|
259
|
+
// Someone refreshed concurrently. Their write carries the newest refresh
|
|
260
|
+
// token; serve what they stored rather than overwrite it.
|
|
261
|
+
const fresh = await deps.store.get(item.linkId)
|
|
262
|
+
if (fresh && fresh.status === 'active' && fresh.accessExpiresAt > now) return fresh
|
|
263
|
+
throw new BrokerError(503, 'conflict', 'concurrent refresh; retry')
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function shortId(linkId) {
|
|
267
|
+
return typeof linkId === 'string' ? linkId.slice(0, 6) : '?'
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── HTTP routes ──────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
const ROUTES = {
|
|
273
|
+
'/broker/token': routeToken,
|
|
274
|
+
'/broker/migrate': routeMigrate,
|
|
275
|
+
'/broker/rotate': routeRotate,
|
|
276
|
+
'/broker/revoke': routeRevoke,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function isBrokerPath(path) {
|
|
280
|
+
return Object.prototype.hasOwnProperty.call(ROUTES, path)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function handleBrokerRoute(event, deps) {
|
|
284
|
+
if (event.requestContext.http.method !== 'POST') {
|
|
285
|
+
return jsonResponse(405, { error: 'method_not_allowed' })
|
|
286
|
+
}
|
|
287
|
+
if (!deps.store) {
|
|
288
|
+
return jsonResponse(503, { error: 'broker_unconfigured', message: 'TOKENS_TABLE is not set' })
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
return await ROUTES[event.rawPath](event, deps)
|
|
292
|
+
} catch (err) {
|
|
293
|
+
if (err instanceof BrokerError) {
|
|
294
|
+
if (err.status >= 500) deps.log.warn('broker: request failed', { route: event.rawPath, code: err.code })
|
|
295
|
+
return jsonResponse(err.status, { error: err.code, message: err.message })
|
|
296
|
+
}
|
|
297
|
+
throw err
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function routeToken(event, deps) {
|
|
302
|
+
const item = await authenticate(deps, event)
|
|
303
|
+
const current = await currentAccessToken(deps, item)
|
|
304
|
+
return jsonResponse(200, { accessToken: current.accessToken, expiresAt: current.accessExpiresAt })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function routeMigrate(event, deps) {
|
|
308
|
+
if (deps.env.ALLOW_MIGRATION === '0') {
|
|
309
|
+
return jsonResponse(403, { error: 'migration_disabled', message: 're-link the skill instead' })
|
|
310
|
+
}
|
|
311
|
+
const brokerUrl = deps.publicUrl
|
|
312
|
+
if (!brokerUrl) {
|
|
313
|
+
return jsonResponse(503, { error: 'broker_unconfigured', message: 'broker public URL unknown' })
|
|
314
|
+
}
|
|
315
|
+
const body = parseJsonBody(event)
|
|
316
|
+
const refreshToken = body && typeof body.refreshToken === 'string' ? body.refreshToken : ''
|
|
317
|
+
if (refreshToken.length === 0 || refreshToken.length > 4096) {
|
|
318
|
+
return jsonResponse(400, { error: 'invalid_request', message: 'refreshToken is required' })
|
|
319
|
+
}
|
|
320
|
+
let tokens
|
|
321
|
+
try {
|
|
322
|
+
tokens = await refreshGrant(deps, refreshToken)
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err instanceof BrokerError && err.code === 'grant_revoked') {
|
|
325
|
+
throw new BrokerError(400, 'invalid_grant', 'not a live grant of this skill')
|
|
326
|
+
}
|
|
327
|
+
throw err
|
|
328
|
+
}
|
|
329
|
+
const hubUrl = body && typeof body.hubUrl === 'string' ? body.hubUrl.slice(0, 512) : undefined
|
|
330
|
+
const { item, credential } = await createLink(deps, {
|
|
331
|
+
status: 'active',
|
|
332
|
+
tokens,
|
|
333
|
+
hubUrl,
|
|
334
|
+
origin: 'migration',
|
|
335
|
+
})
|
|
336
|
+
deps.log.info('broker: migrated a pre-broker grant', { link: shortId(item.linkId) })
|
|
337
|
+
return jsonResponse(200, {
|
|
338
|
+
brokerCredential: credential,
|
|
339
|
+
brokerUrl,
|
|
340
|
+
accessToken: item.accessToken,
|
|
341
|
+
expiresAt: item.accessExpiresAt,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function routeRotate(event, deps) {
|
|
346
|
+
const item = await authenticate(deps, event)
|
|
347
|
+
const minted = mintCredential(deps.randomBytes, item.linkId)
|
|
348
|
+
const next = { ...item, secretHash: minted.secretHash, version: item.version + 1 }
|
|
349
|
+
if (!(await deps.store.replace(next, item.version))) {
|
|
350
|
+
throw new BrokerError(409, 'conflict', 'link changed while rotating; retry')
|
|
351
|
+
}
|
|
352
|
+
deps.log.info('broker: credential rotated', { link: shortId(item.linkId) })
|
|
353
|
+
return jsonResponse(200, { brokerCredential: minted.credential })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function routeRevoke(event, deps) {
|
|
357
|
+
const item = await authenticate(deps, event)
|
|
358
|
+
await deps.store.remove(item.linkId)
|
|
359
|
+
deps.log.info('broker: link revoked by its hub', { link: shortId(item.linkId) })
|
|
360
|
+
return jsonResponse(200, { ok: true })
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
module.exports = {
|
|
364
|
+
BrokerError,
|
|
365
|
+
LWA_TOKEN_URL,
|
|
366
|
+
ACCESS_TOKEN_MIN_LIFE_MS,
|
|
367
|
+
PENDING_TTL_SEC,
|
|
368
|
+
IDLE_TTL_SEC,
|
|
369
|
+
mintCredential,
|
|
370
|
+
parseCredential,
|
|
371
|
+
secretMatches,
|
|
372
|
+
sha256Hex,
|
|
373
|
+
lwaTokenCall,
|
|
374
|
+
exchangeGrantCode,
|
|
375
|
+
refreshGrant,
|
|
376
|
+
createLink,
|
|
377
|
+
activateLink,
|
|
378
|
+
currentAccessToken,
|
|
379
|
+
isBrokerPath,
|
|
380
|
+
handleBrokerRoute,
|
|
381
|
+
shortId,
|
|
382
|
+
}
|