@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,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `timedFetch` — the Lambda's only outbound HTTP primitive.
|
|
3
|
+
*
|
|
4
|
+
* Uses https.request with a no-keep-alive Agent. Node's built-in fetch
|
|
5
|
+
* (undici-backed) reuses pooled sockets aggressively; in AWS Lambda the
|
|
6
|
+
* invocation-to-invocation gaps can leave pooled sockets stale, and the next
|
|
7
|
+
* fetch throws DOMException AbortError in <1s. https.request gives us explicit
|
|
8
|
+
* control.
|
|
9
|
+
*
|
|
10
|
+
* NEVER logs request or response bodies, headers or query strings: they carry
|
|
11
|
+
* OAuth codes, LWA tokens and broker credentials. The timing line records only
|
|
12
|
+
* scheme, host, path, status and sub-timings.
|
|
13
|
+
*/
|
|
14
|
+
'use strict'
|
|
15
|
+
|
|
16
|
+
const https = require('node:https')
|
|
17
|
+
const http = require('node:http')
|
|
18
|
+
const { URL } = require('node:url')
|
|
19
|
+
|
|
20
|
+
const FETCH_TIMEOUT_MS = 6000
|
|
21
|
+
const NO_KEEPALIVE_AGENT = new https.Agent({ keepAlive: false })
|
|
22
|
+
|
|
23
|
+
async function timedFetch(url, init = {}) {
|
|
24
|
+
// Returns a fetch-compatible response object: { ok, status, statusText,
|
|
25
|
+
// text(), json() }. The handler only uses .text() / status.
|
|
26
|
+
//
|
|
27
|
+
// Emits a single structured timing log line on completion (or failure),
|
|
28
|
+
// measuring DNS / TCP / TLS / request-sent / first-byte / last-byte sub-
|
|
29
|
+
// timings. Read these from CloudWatch with:
|
|
30
|
+
// filter @message like /timedFetch_timing/
|
|
31
|
+
// to see end-to-end where the wall-clock seconds go (DNS hang vs TCP RTT
|
|
32
|
+
// vs TLS handshake vs hub processing time vs body download).
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let settled = false
|
|
35
|
+
const parsed = new URL(url)
|
|
36
|
+
const isHttps = parsed.protocol === 'https:'
|
|
37
|
+
const lib = isHttps ? https : http
|
|
38
|
+
const headers = { ...(init.headers || {}), connection: 'close' }
|
|
39
|
+
if (init.body !== undefined && headers['content-length'] === undefined) {
|
|
40
|
+
headers['content-length'] = Buffer.byteLength(init.body)
|
|
41
|
+
}
|
|
42
|
+
const T0 = process.hrtime.bigint()
|
|
43
|
+
const marks = {
|
|
44
|
+
dns: null,
|
|
45
|
+
tcpConnect: null,
|
|
46
|
+
tlsHandshake: null,
|
|
47
|
+
requestSent: null,
|
|
48
|
+
firstByte: null,
|
|
49
|
+
lastByte: null,
|
|
50
|
+
}
|
|
51
|
+
const ms = (a, b) => Number((b - a) / 1_000_000n)
|
|
52
|
+
const logTiming = (status, extras = {}) => {
|
|
53
|
+
const T_end = process.hrtime.bigint()
|
|
54
|
+
const total = ms(T0, T_end)
|
|
55
|
+
const stages = {
|
|
56
|
+
dns: marks.dns ? ms(T0, marks.dns) : null,
|
|
57
|
+
tcp: marks.tcpConnect ? ms(T0, marks.tcpConnect) : null,
|
|
58
|
+
tls: marks.tlsHandshake ? ms(T0, marks.tlsHandshake) : null,
|
|
59
|
+
reqSent: marks.requestSent ? ms(T0, marks.requestSent) : null,
|
|
60
|
+
firstByte: marks.firstByte ? ms(T0, marks.firstByte) : null,
|
|
61
|
+
lastByte: marks.lastByte ? ms(T0, marks.lastByte) : null,
|
|
62
|
+
}
|
|
63
|
+
// Stage durations between marks, easier to read at a glance:
|
|
64
|
+
const between = {
|
|
65
|
+
dns_ms: stages.dns,
|
|
66
|
+
tcp_handshake_ms:
|
|
67
|
+
stages.tcp != null && stages.dns != null ? stages.tcp - stages.dns : stages.tcp,
|
|
68
|
+
tls_handshake_ms: stages.tls != null && stages.tcp != null ? stages.tls - stages.tcp : null,
|
|
69
|
+
request_send_ms:
|
|
70
|
+
stages.reqSent != null && (stages.tls ?? stages.tcp) != null
|
|
71
|
+
? stages.reqSent - (stages.tls ?? stages.tcp)
|
|
72
|
+
: null,
|
|
73
|
+
wait_first_byte_ms:
|
|
74
|
+
stages.firstByte != null && stages.reqSent != null
|
|
75
|
+
? stages.firstByte - stages.reqSent
|
|
76
|
+
: null,
|
|
77
|
+
download_ms:
|
|
78
|
+
stages.lastByte != null && stages.firstByte != null
|
|
79
|
+
? stages.lastByte - stages.firstByte
|
|
80
|
+
: null,
|
|
81
|
+
}
|
|
82
|
+
console.log(
|
|
83
|
+
'timedFetch_timing',
|
|
84
|
+
JSON.stringify({
|
|
85
|
+
url: `${parsed.protocol}//${parsed.hostname}${parsed.pathname}`,
|
|
86
|
+
method: (init.method || 'GET').toUpperCase(),
|
|
87
|
+
status,
|
|
88
|
+
total_ms: total,
|
|
89
|
+
stages_cum_ms: stages,
|
|
90
|
+
between_ms: between,
|
|
91
|
+
...extras,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
const req = lib.request(
|
|
96
|
+
{
|
|
97
|
+
protocol: parsed.protocol,
|
|
98
|
+
hostname: parsed.hostname,
|
|
99
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
100
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
101
|
+
method: (init.method || 'GET').toUpperCase(),
|
|
102
|
+
headers,
|
|
103
|
+
agent: isHttps ? NO_KEEPALIVE_AGENT : undefined,
|
|
104
|
+
},
|
|
105
|
+
(res) => {
|
|
106
|
+
marks.firstByte = process.hrtime.bigint()
|
|
107
|
+
const chunks = []
|
|
108
|
+
res.on('data', (c) => chunks.push(c))
|
|
109
|
+
res.on('end', () => {
|
|
110
|
+
if (settled) return
|
|
111
|
+
settled = true
|
|
112
|
+
clearTimeout(timer)
|
|
113
|
+
marks.lastByte = process.hrtime.bigint()
|
|
114
|
+
const body = Buffer.concat(chunks).toString('utf8')
|
|
115
|
+
const headersApi = {
|
|
116
|
+
get: (name) => {
|
|
117
|
+
const v = res.headers[String(name).toLowerCase()]
|
|
118
|
+
if (Array.isArray(v)) return v.join(', ')
|
|
119
|
+
return v == null ? null : String(v)
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
logTiming(res.statusCode, { responseBodyBytes: body.length })
|
|
123
|
+
resolve({
|
|
124
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
125
|
+
status: res.statusCode,
|
|
126
|
+
statusText: res.statusMessage || '',
|
|
127
|
+
headers: headersApi,
|
|
128
|
+
text: async () => body,
|
|
129
|
+
json: async () => JSON.parse(body),
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
res.on('error', (err) => {
|
|
133
|
+
if (settled) return
|
|
134
|
+
settled = true
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
logTiming('res_error', { error: err && err.message })
|
|
137
|
+
reject(err)
|
|
138
|
+
})
|
|
139
|
+
},
|
|
140
|
+
)
|
|
141
|
+
req.on('socket', (socket) => {
|
|
142
|
+
socket.on('lookup', () => {
|
|
143
|
+
marks.dns = process.hrtime.bigint()
|
|
144
|
+
})
|
|
145
|
+
socket.on('connect', () => {
|
|
146
|
+
marks.tcpConnect = process.hrtime.bigint()
|
|
147
|
+
})
|
|
148
|
+
socket.on('secureConnect', () => {
|
|
149
|
+
marks.tlsHandshake = process.hrtime.bigint()
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
req.on('finish', () => {
|
|
153
|
+
marks.requestSent = process.hrtime.bigint()
|
|
154
|
+
})
|
|
155
|
+
req.on('error', (err) => {
|
|
156
|
+
if (settled) return
|
|
157
|
+
settled = true
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
logTiming('req_error', { error: err && err.message, code: err && err.code })
|
|
160
|
+
reject(err)
|
|
161
|
+
})
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
if (settled) return
|
|
164
|
+
settled = true
|
|
165
|
+
req.destroy(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
|
|
166
|
+
logTiming('timeout', { timeout_ms: FETCH_TIMEOUT_MS })
|
|
167
|
+
reject(new Error(`timedFetch timeout after ${FETCH_TIMEOUT_MS}ms`))
|
|
168
|
+
}, FETCH_TIMEOUT_MS)
|
|
169
|
+
if (init.body !== undefined) req.write(init.body)
|
|
170
|
+
req.end()
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { timedFetch, FETCH_TIMEOUT_MS }
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CamStack Alexa Smart Home Lambda — one skill, many hubs, and the skill's
|
|
3
|
+
* token broker (D753).
|
|
4
|
+
*
|
|
5
|
+
* One Lambda + one skill serves every CamStack hub. Directives are routed by
|
|
6
|
+
* the `hubUrl` claim each hub bakes into the OAuth tokens it mints. Since D753
|
|
7
|
+
* the Lambda is also the ONLY holder of the skill's LWA client secret: it
|
|
8
|
+
* exchanges AcceptGrant codes itself, keeps each user's LWA refresh token in
|
|
9
|
+
* DynamoDB, and hands hubs short-lived Event Gateway access tokens against a
|
|
10
|
+
* per-link credential (see broker.js).
|
|
11
|
+
*
|
|
12
|
+
* Invocation shapes:
|
|
13
|
+
* 1. Function URL GET /authorize → hub-URL entry form
|
|
14
|
+
* 2. Function URL POST /token → OAuth token exchange, proxied to the hub
|
|
15
|
+
* 3. Function URL POST /broker/* → token broker (broker.js)
|
|
16
|
+
* 4. Smart Home directive:
|
|
17
|
+
* - Alexa.Authorization/AcceptGrant → exchange the LWA code here, store the
|
|
18
|
+
* grant, give the hub a broker credential (legacy: give it the tokens)
|
|
19
|
+
* - everything else → forward to the hub
|
|
20
|
+
*
|
|
21
|
+
* Configuration (env — none of these is a secret once D753 is deployed):
|
|
22
|
+
* LWA_SECRET_ID Secrets Manager id of {"clientId","clientSecret"} for
|
|
23
|
+
* the skill's "Send Alexa Events" LWA client.
|
|
24
|
+
* SKILL_SECRET_ID Secrets Manager id of {"clientId","clientSecret"
|
|
25
|
+
* [,"previousClientSecret"]} for account linking.
|
|
26
|
+
* TOKENS_TABLE DynamoDB table of broker links. Unset = broker off
|
|
27
|
+
* (pre-D753 behaviour: tokens are handed to the hub).
|
|
28
|
+
* LAMBDA_REDIRECT_URI Function URL + "/token"; the AcceptGrant redirect_uri.
|
|
29
|
+
* BROKER_PUBLIC_URL Optional; defaults to LAMBDA_REDIRECT_URI's origin.
|
|
30
|
+
* LEGACY_HUB_FALLBACK "0" = a hub without the broker route fails AcceptGrant
|
|
31
|
+
* instead of receiving the refresh token. Default on.
|
|
32
|
+
* ALLOW_MIGRATION "0" = refuse /broker/migrate. Default on.
|
|
33
|
+
* HUB_INGRESS_TOKEN Optional defense-in-depth header for hub calls.
|
|
34
|
+
* Legacy (read only when the *_SECRET_ID is unset): LWA_CLIENT_ID,
|
|
35
|
+
* LWA_CLIENT_SECRET, SKILL_CLIENT_ID, SKILL_CLIENT_SECRET.
|
|
36
|
+
*
|
|
37
|
+
* Ships as plain files — no node_modules, no transpile (`zip` the folder's
|
|
38
|
+
* .js files). AWS SDK v3 comes with the nodejs22.x runtime.
|
|
39
|
+
*/
|
|
40
|
+
'use strict'
|
|
41
|
+
|
|
42
|
+
const crypto = require('node:crypto')
|
|
43
|
+
|
|
44
|
+
const { createAwsClients } = require('./aws.js')
|
|
45
|
+
const {
|
|
46
|
+
activateLink,
|
|
47
|
+
createLink,
|
|
48
|
+
exchangeGrantCode,
|
|
49
|
+
handleBrokerRoute,
|
|
50
|
+
isBrokerPath,
|
|
51
|
+
shortId,
|
|
52
|
+
} = require('./broker.js')
|
|
53
|
+
const { timedFetch } = require('./http.js')
|
|
54
|
+
const { createSecretSource } = require('./secrets.js')
|
|
55
|
+
const { createDynamoTokenStore } = require('./token-store.js')
|
|
56
|
+
const {
|
|
57
|
+
decodeBody,
|
|
58
|
+
decodeJwtPayload,
|
|
59
|
+
describe,
|
|
60
|
+
encodeFormUrlEncoded,
|
|
61
|
+
errorEnvelope,
|
|
62
|
+
jsonResponse,
|
|
63
|
+
normaliseHubUrl,
|
|
64
|
+
parseFormUrlEncoded,
|
|
65
|
+
resolveBrokerPublicUrl,
|
|
66
|
+
} = require('./util.js')
|
|
67
|
+
|
|
68
|
+
/** The header that tells a hub where its skill's broker lives (D753). */
|
|
69
|
+
const BROKER_HEADER = 'x-camstack-broker'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the dependency set once per container — the secret cache and the SDK
|
|
73
|
+
* clients must survive across invocations. Anything passed in wins (tests).
|
|
74
|
+
*/
|
|
75
|
+
function resolveDeps(overrides) {
|
|
76
|
+
const o = overrides || {}
|
|
77
|
+
const env = o.env || process.env
|
|
78
|
+
let aws = null
|
|
79
|
+
const awsClients = () => {
|
|
80
|
+
if (!aws) aws = createAwsClients()
|
|
81
|
+
return aws
|
|
82
|
+
}
|
|
83
|
+
const store =
|
|
84
|
+
o.store !== undefined
|
|
85
|
+
? o.store
|
|
86
|
+
: env.TOKENS_TABLE
|
|
87
|
+
? createDynamoTokenStore({
|
|
88
|
+
send: (name, input) => awsClients().send(name, input),
|
|
89
|
+
tableName: env.TOKENS_TABLE,
|
|
90
|
+
})
|
|
91
|
+
: null
|
|
92
|
+
return {
|
|
93
|
+
env,
|
|
94
|
+
fetch: o.fetch || timedFetch,
|
|
95
|
+
now: o.now || Date.now,
|
|
96
|
+
randomBytes: o.randomBytes || crypto.randomBytes,
|
|
97
|
+
log: o.log || console,
|
|
98
|
+
secrets:
|
|
99
|
+
o.secrets ||
|
|
100
|
+
createSecretSource({
|
|
101
|
+
env,
|
|
102
|
+
fetchSecretString: (id) => awsClients().fetchSecretString(id),
|
|
103
|
+
}),
|
|
104
|
+
store,
|
|
105
|
+
publicUrl: resolveBrokerPublicUrl(env),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function createHandler(overrides) {
|
|
110
|
+
let deps = null
|
|
111
|
+
return async (event) => {
|
|
112
|
+
if (!deps) deps = resolveDeps(overrides)
|
|
113
|
+
try {
|
|
114
|
+
if (event && event.requestContext && event.requestContext.http) {
|
|
115
|
+
return await handleLambdaUrl(event, deps)
|
|
116
|
+
}
|
|
117
|
+
if (event && event.directive && event.directive.header) {
|
|
118
|
+
return await handleDirective(event, deps)
|
|
119
|
+
}
|
|
120
|
+
return jsonResponse(400, { error: 'unknown_event' })
|
|
121
|
+
} catch (err) {
|
|
122
|
+
deps.log.error('camstack-alexa: unhandled error', describe(err))
|
|
123
|
+
if (event && event.directive && event.directive.header) {
|
|
124
|
+
return errorEnvelope(event.directive.header, 'INTERNAL_ERROR', describe(err))
|
|
125
|
+
}
|
|
126
|
+
return jsonResponse(500, { error: 'internal_error', message: describe(err) })
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Lambda Function URL (HTTP) routes ────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async function handleLambdaUrl(event, deps) {
|
|
134
|
+
const path = event.rawPath || '/'
|
|
135
|
+
if (path === '/authorize') return handleAuthorizePage(event)
|
|
136
|
+
if (path === '/token') return handleTokenProxy(event, deps)
|
|
137
|
+
if (isBrokerPath(path)) return handleBrokerRoute(event, deps)
|
|
138
|
+
return { statusCode: 404, headers: { 'content-type': 'text/plain' }, body: 'Not found' }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Render the "Enter your CamStack hub URL" form. Submitting the form
|
|
143
|
+
* forwards the original Alexa query params (response_type, client_id,
|
|
144
|
+
* redirect_uri, state, scope) — plus `integration=export-alexa` — to
|
|
145
|
+
* `${hubUrl}/api/oauth2/authorize`. From there the browser stays on the hub's
|
|
146
|
+
* origin until the OAuth code round-trips back to Alexa.
|
|
147
|
+
*/
|
|
148
|
+
function handleAuthorizePage(event) {
|
|
149
|
+
const qs = event.rawQueryString || ''
|
|
150
|
+
const html = `<!doctype html>
|
|
151
|
+
<html lang="en">
|
|
152
|
+
<head>
|
|
153
|
+
<meta charset="utf-8">
|
|
154
|
+
<title>CamStack · Link your hub</title>
|
|
155
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
156
|
+
<style>
|
|
157
|
+
body { font-family: system-ui, -apple-system, sans-serif; max-width: 480px; margin: 4rem auto; padding: 0 1rem; color: #1f2937; background: #f9fafb; }
|
|
158
|
+
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 2rem; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
|
|
159
|
+
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
|
160
|
+
p { font-size: 0.9rem; color: #4b5563; margin: 0 0 1.5rem; }
|
|
161
|
+
label { display: block; font-size: 0.85rem; font-weight: 500; margin-bottom: 0.5rem; }
|
|
162
|
+
input { width: 100%; padding: 0.6rem 0.75rem; font-size: 0.95rem; border: 1px solid #d1d5db; border-radius: 0.375rem; box-sizing: border-box; font-family: ui-monospace, Menlo, monospace; }
|
|
163
|
+
input:focus { outline: 2px solid #2563eb; outline-offset: 1px; border-color: #2563eb; }
|
|
164
|
+
button { margin-top: 1rem; width: 100%; padding: 0.7rem; font-size: 0.95rem; font-weight: 500; background: #2563eb; color: white; border: none; border-radius: 0.375rem; cursor: pointer; }
|
|
165
|
+
button:hover { background: #1d4ed8; }
|
|
166
|
+
.hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
|
|
167
|
+
</style>
|
|
168
|
+
</head>
|
|
169
|
+
<body>
|
|
170
|
+
<div class="card">
|
|
171
|
+
<h1>Link CamStack to Alexa</h1>
|
|
172
|
+
<p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
|
|
173
|
+
<form id="f">
|
|
174
|
+
<label for="hubUrl">Hub URL</label>
|
|
175
|
+
<input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
|
|
176
|
+
<p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
|
|
177
|
+
<button type="submit">Continue</button>
|
|
178
|
+
</form>
|
|
179
|
+
</div>
|
|
180
|
+
<script>
|
|
181
|
+
document.getElementById('f').addEventListener('submit', function (ev) {
|
|
182
|
+
ev.preventDefault()
|
|
183
|
+
var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/$/, '')
|
|
184
|
+
if (!hubUrl) return
|
|
185
|
+
var qs = ${JSON.stringify(qs)}
|
|
186
|
+
var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
|
|
187
|
+
window.location.href = url
|
|
188
|
+
})
|
|
189
|
+
</script>
|
|
190
|
+
</body>
|
|
191
|
+
</html>`
|
|
192
|
+
return {
|
|
193
|
+
statusCode: 200,
|
|
194
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
195
|
+
body: html,
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Constant-time string equality that does not leak length via timing. */
|
|
200
|
+
function safeEqual(a, b) {
|
|
201
|
+
const ab = Buffer.from(String(a), 'utf8')
|
|
202
|
+
const bb = Buffer.from(String(b), 'utf8')
|
|
203
|
+
if (ab.length !== bb.length) {
|
|
204
|
+
crypto.timingSafeEqual(ab, ab)
|
|
205
|
+
return false
|
|
206
|
+
}
|
|
207
|
+
return crypto.timingSafeEqual(ab, bb)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Proxy the OAuth token exchange. Alexa POSTs a form-urlencoded body with the
|
|
212
|
+
* account-linking client_id/client_secret; we verify those (current secret, or
|
|
213
|
+
* `previousClientSecret` mid-rotation), decode the code/refresh_token JWT
|
|
214
|
+
* payload to find the hub URL claim, then forward the body to the hub's own
|
|
215
|
+
* /api/oauth2/token endpoint.
|
|
216
|
+
*/
|
|
217
|
+
async function handleTokenProxy(event, deps) {
|
|
218
|
+
if (event.requestContext.http.method !== 'POST') {
|
|
219
|
+
return jsonResponse(405, { error: 'method_not_allowed' })
|
|
220
|
+
}
|
|
221
|
+
const body = parseFormUrlEncoded(decodeBody(event))
|
|
222
|
+
const skill = await deps.secrets.skill()
|
|
223
|
+
if (!skill) {
|
|
224
|
+
return jsonResponse(500, {
|
|
225
|
+
error: 'unconfigured',
|
|
226
|
+
message: 'account-linking client credentials are not configured',
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
const secretOk =
|
|
230
|
+
typeof body.client_secret === 'string' &&
|
|
231
|
+
(safeEqual(body.client_secret, skill.clientSecret) ||
|
|
232
|
+
(skill.previousClientSecret !== undefined &&
|
|
233
|
+
safeEqual(body.client_secret, skill.previousClientSecret)))
|
|
234
|
+
if (body.client_id !== skill.clientId || !secretOk) {
|
|
235
|
+
return jsonResponse(401, { error: 'invalid_client', message: 'client credentials mismatch' })
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const tokenInput = body.grant_type === 'refresh_token' ? body.refresh_token : body.code
|
|
239
|
+
if (!tokenInput) {
|
|
240
|
+
return jsonResponse(400, {
|
|
241
|
+
error: 'invalid_request',
|
|
242
|
+
message: 'code or refresh_token is required',
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
const claims = decodeJwtPayload(tokenInput)
|
|
246
|
+
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
247
|
+
return jsonResponse(400, {
|
|
248
|
+
error: 'invalid_grant',
|
|
249
|
+
message: 'token has no hubUrl claim — was it minted by a hub that supports cloud-mode?',
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const target = `${normaliseHubUrl(claims.hubUrl)}/api/oauth2/token`
|
|
254
|
+
const upstream = await deps.fetch(target, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
257
|
+
body: encodeFormUrlEncoded(body),
|
|
258
|
+
})
|
|
259
|
+
const text = await upstream.text()
|
|
260
|
+
return {
|
|
261
|
+
statusCode: upstream.status,
|
|
262
|
+
headers: { 'content-type': upstream.headers.get('content-type') || 'application/json' },
|
|
263
|
+
body: text,
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Smart Home directives ────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
async function handleDirective(event, deps) {
|
|
270
|
+
const header = event.directive.header
|
|
271
|
+
const ns = `${header.namespace}/${header.name}`
|
|
272
|
+
if (ns === 'Alexa.Authorization/AcceptGrant') {
|
|
273
|
+
return handleAcceptGrant(event, deps)
|
|
274
|
+
}
|
|
275
|
+
return forwardDirective(event, deps)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hubHeaders(deps, bearer) {
|
|
279
|
+
return {
|
|
280
|
+
'content-type': 'application/json',
|
|
281
|
+
authorization: `Bearer ${bearer}`,
|
|
282
|
+
// Defense-in-depth shared secret — the export-alexa "Ingress token"
|
|
283
|
+
// setting. Sent only when configured; the hub enforces it when the
|
|
284
|
+
// operator turns on "Require ingress token".
|
|
285
|
+
...(deps.env.HUB_INGRESS_TOKEN ? { 'x-camstack-ingress': deps.env.HUB_INGRESS_TOKEN } : {}),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function brokerAvailable(deps) {
|
|
290
|
+
return deps.store !== null && typeof deps.publicUrl === 'string'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function acceptGrantResponse(header) {
|
|
294
|
+
return {
|
|
295
|
+
event: {
|
|
296
|
+
header: {
|
|
297
|
+
namespace: 'Alexa.Authorization',
|
|
298
|
+
name: 'AcceptGrant.Response',
|
|
299
|
+
messageId: header.messageId,
|
|
300
|
+
payloadVersion: '3',
|
|
301
|
+
},
|
|
302
|
+
payload: {},
|
|
303
|
+
},
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function handleAcceptGrant(event, deps) {
|
|
308
|
+
const header = event.directive.header
|
|
309
|
+
const payload = event.directive.payload || {}
|
|
310
|
+
const grantCode = payload.grant && payload.grant.code
|
|
311
|
+
const granteeToken = payload.grantee && payload.grantee.token
|
|
312
|
+
if (!grantCode || !granteeToken) {
|
|
313
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'payload missing grant.code or grantee.token')
|
|
314
|
+
}
|
|
315
|
+
const claims = decodeJwtPayload(granteeToken)
|
|
316
|
+
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
317
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'grantee token has no hubUrl claim')
|
|
318
|
+
}
|
|
319
|
+
const redirectUri = deps.env.LAMBDA_REDIRECT_URI
|
|
320
|
+
if (!redirectUri) {
|
|
321
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'LAMBDA_REDIRECT_URI not set on the Lambda')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
let tokens
|
|
325
|
+
try {
|
|
326
|
+
tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
|
|
327
|
+
} catch (err) {
|
|
328
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', describe(err))
|
|
329
|
+
}
|
|
330
|
+
const hubUrl = normaliseHubUrl(claims.hubUrl)
|
|
331
|
+
|
|
332
|
+
if (brokerAvailable(deps)) {
|
|
333
|
+
const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
|
|
334
|
+
if (outcome.kind === 'linked') return acceptGrantResponse(header)
|
|
335
|
+
if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
|
|
336
|
+
return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', outcome.message)
|
|
337
|
+
}
|
|
338
|
+
deps.log.warn('accept_grant: hub has no broker route; falling back to the legacy token hand-off')
|
|
339
|
+
}
|
|
340
|
+
return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The D753 link: store the grant here, give the hub only a credential and a
|
|
345
|
+
* first access token. The item stays `pending` until the hub accepted it, so a
|
|
346
|
+
* hub that refuses (or does not have the route) leaves nothing behind.
|
|
347
|
+
*/
|
|
348
|
+
async function linkViaBroker(deps, input) {
|
|
349
|
+
const { item, credential } = await createLink(deps, {
|
|
350
|
+
status: 'pending',
|
|
351
|
+
tokens: input.tokens,
|
|
352
|
+
hubUrl: input.hubUrl,
|
|
353
|
+
origin: 'accept-grant',
|
|
354
|
+
})
|
|
355
|
+
let resp
|
|
356
|
+
try {
|
|
357
|
+
resp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/broker-link`, {
|
|
358
|
+
method: 'POST',
|
|
359
|
+
headers: hubHeaders(deps, input.granteeToken),
|
|
360
|
+
body: JSON.stringify({
|
|
361
|
+
brokerUrl: deps.publicUrl,
|
|
362
|
+
brokerCredential: credential,
|
|
363
|
+
lwaAccessToken: item.accessToken,
|
|
364
|
+
expiresAt: item.accessExpiresAt,
|
|
365
|
+
}),
|
|
366
|
+
})
|
|
367
|
+
} catch (err) {
|
|
368
|
+
await deps.store.remove(item.linkId)
|
|
369
|
+
return { kind: 'failed', message: `hub broker-link unreachable: ${describe(err)}` }
|
|
370
|
+
}
|
|
371
|
+
if (resp.ok) {
|
|
372
|
+
await activateLink(deps, item)
|
|
373
|
+
deps.log.info('accept_grant: linked via broker', { link: shortId(item.linkId) })
|
|
374
|
+
return { kind: 'linked' }
|
|
375
|
+
}
|
|
376
|
+
await deps.store.remove(item.linkId)
|
|
377
|
+
if (resp.status === 404) return { kind: 'hub-without-broker', message: 'hub broker-link 404' }
|
|
378
|
+
const text = await resp.text().catch(() => '')
|
|
379
|
+
return { kind: 'failed', message: `hub broker-link ${resp.status}: ${text.slice(0, 200)}` }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Pre-D753 hand-off: the hub receives the refresh token itself. */
|
|
383
|
+
async function deliverTokensToLegacyHub(deps, header, input) {
|
|
384
|
+
const callbackResp = await deps.fetch(`${input.hubUrl}/addon/export-alexa/oauth/lwa-tokens`, {
|
|
385
|
+
method: 'POST',
|
|
386
|
+
headers: hubHeaders(deps, input.granteeToken),
|
|
387
|
+
body: JSON.stringify({
|
|
388
|
+
lwaAccessToken: input.tokens.accessToken,
|
|
389
|
+
lwaRefreshToken: input.tokens.refreshToken,
|
|
390
|
+
expiresAt: deps.now() + input.tokens.expiresInSec * 1000,
|
|
391
|
+
}),
|
|
392
|
+
})
|
|
393
|
+
if (!callbackResp.ok) {
|
|
394
|
+
const text = await callbackResp.text().catch(() => '')
|
|
395
|
+
return errorEnvelope(
|
|
396
|
+
header,
|
|
397
|
+
'ACCEPT_GRANT_FAILED',
|
|
398
|
+
`hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
return acceptGrantResponse(header)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function forwardDirective(event, deps) {
|
|
405
|
+
const header = event.directive.header
|
|
406
|
+
const bearer = extractBearer(event.directive)
|
|
407
|
+
if (!bearer) {
|
|
408
|
+
return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'directive has no bearer token')
|
|
409
|
+
}
|
|
410
|
+
const claims = decodeJwtPayload(bearer)
|
|
411
|
+
if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
|
|
412
|
+
return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'bearer has no hubUrl claim')
|
|
413
|
+
}
|
|
414
|
+
const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
|
|
415
|
+
const resp = await deps.fetch(target, {
|
|
416
|
+
method: 'POST',
|
|
417
|
+
headers: {
|
|
418
|
+
...hubHeaders(deps, bearer),
|
|
419
|
+
// Where this skill's broker lives. A hub linked before D753 uses it to
|
|
420
|
+
// migrate its refresh token here (zero-touch); the hub only believes it
|
|
421
|
+
// on a directive whose bearer it verified.
|
|
422
|
+
...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
|
|
423
|
+
},
|
|
424
|
+
body: JSON.stringify(event),
|
|
425
|
+
})
|
|
426
|
+
const text = await resp.text()
|
|
427
|
+
try {
|
|
428
|
+
return JSON.parse(text)
|
|
429
|
+
} catch {
|
|
430
|
+
return errorEnvelope(header, 'INTERNAL_ERROR', `hub returned non-JSON: ${text.slice(0, 256)}`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function extractBearer(directive) {
|
|
435
|
+
if (
|
|
436
|
+
directive.endpoint &&
|
|
437
|
+
directive.endpoint.scope &&
|
|
438
|
+
typeof directive.endpoint.scope.token === 'string'
|
|
439
|
+
) {
|
|
440
|
+
return directive.endpoint.scope.token
|
|
441
|
+
}
|
|
442
|
+
if (
|
|
443
|
+
directive.payload &&
|
|
444
|
+
directive.payload.scope &&
|
|
445
|
+
typeof directive.payload.scope.token === 'string'
|
|
446
|
+
) {
|
|
447
|
+
return directive.payload.scope.token
|
|
448
|
+
}
|
|
449
|
+
return null
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
exports.handler = createHandler()
|
|
453
|
+
exports.createHandler = createHandler
|
|
454
|
+
exports.BROKER_HEADER = BROKER_HEADER
|