@camstack/server 1.2.82 → 1.2.83
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/api/oauth2/oauth2-routes.js +63 -15
- package/package.json +4 -4
|
@@ -9,6 +9,15 @@ const consent_page_js_1 = require("./consent-page.js");
|
|
|
9
9
|
const private_host_js_1 = require("./private-host.js");
|
|
10
10
|
/** Longest caller-supplied `integration` value echoed back in an error. */
|
|
11
11
|
const MAX_ECHOED_INTEGRATION_LEN = 100;
|
|
12
|
+
/**
|
|
13
|
+
* `Retry-After` on a refusal caused by an addon that has not registered yet.
|
|
14
|
+
*
|
|
15
|
+
* A forked addon's runner spawns, loads and initialises in seconds. This is
|
|
16
|
+
* advice to the CLIENT and nothing more — the hub never sleeps, never polls
|
|
17
|
+
* and never queues the request ([D3](../../../../../docs/decisions/adr-0003.md));
|
|
18
|
+
* it answers immediately with what is true right now.
|
|
19
|
+
*/
|
|
20
|
+
const INTEGRATION_PENDING_RETRY_AFTER_SEC = 5;
|
|
12
21
|
/** Render the received `integration` for a human, bounded. */
|
|
13
22
|
function describeReceivedIntegration(raw) {
|
|
14
23
|
if (raw === undefined)
|
|
@@ -48,15 +57,16 @@ function resolveIntegrationParam(raw) {
|
|
|
48
57
|
* NOT checked — that pair is verified only at the Lambda boundary, and a
|
|
49
58
|
* PUBLIC client (`requiresPkce`) has no secret to check it against at all;
|
|
50
59
|
* the S256 challenge is what binds the code to its requester instead. */
|
|
51
|
-
function validateAuthorizeQuery(q, knownIntegrations) {
|
|
60
|
+
function validateAuthorizeQuery(q, knownIntegrations, pendingAddons = []) {
|
|
52
61
|
if (q.response_type !== 'code')
|
|
53
62
|
return { ok: false, status: 400, error: 'unsupported_response_type' };
|
|
54
|
-
//
|
|
63
|
+
// Four distinct faults used to collapse into one opaque string.
|
|
55
64
|
// Alexa linking was down for a day because the message named neither what
|
|
56
65
|
// arrived nor what would have been accepted.
|
|
57
66
|
const detail = {
|
|
58
67
|
received: describeReceivedIntegration(q.integration),
|
|
59
68
|
known_integrations: [...knownIntegrations.keys()],
|
|
69
|
+
...(pendingAddons.length > 0 ? { pending_addons: pendingAddons } : {}),
|
|
60
70
|
};
|
|
61
71
|
const param = resolveIntegrationParam(q.integration);
|
|
62
72
|
if (!param.ok) {
|
|
@@ -67,6 +77,21 @@ function validateAuthorizeQuery(q, knownIntegrations) {
|
|
|
67
77
|
}
|
|
68
78
|
const policy = knownIntegrations.get(param.value);
|
|
69
79
|
if (!policy) {
|
|
80
|
+
// The fourth fault, and the one moving every descriptor into an addon made
|
|
81
|
+
// likelier: the id is absent because the addon that owns it has not
|
|
82
|
+
// registered yet. `invalid_request` means "your request is wrong, do not
|
|
83
|
+
// repeat it" — during boot that is a lie, and it is the lie that sends a
|
|
84
|
+
// client to its degraded fallback and leaves it there. `temporarily_
|
|
85
|
+
// unavailable` is RFC 6749 §4.1.2.1 and says the opposite.
|
|
86
|
+
if (pendingAddons.length > 0) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
status: 503,
|
|
90
|
+
error: 'temporarily_unavailable — integration not registered yet',
|
|
91
|
+
detail,
|
|
92
|
+
retryAfterSec: INTEGRATION_PENDING_RETRY_AFTER_SEC,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
70
95
|
return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
|
|
71
96
|
}
|
|
72
97
|
if (!q.redirect_uri)
|
|
@@ -138,19 +163,29 @@ function refuseAuthorize(reply, logger, method, refusal) {
|
|
|
138
163
|
method,
|
|
139
164
|
received: refusal.detail?.received ?? null,
|
|
140
165
|
knownIntegrations: refusal.detail?.known_integrations ?? [],
|
|
166
|
+
// Without this, a refusal during the boot window is byte-identical in
|
|
167
|
+
// Loki to one against a hub that has been up for a week.
|
|
168
|
+
pendingAddons: refusal.detail?.pending_addons ?? [],
|
|
141
169
|
},
|
|
142
170
|
});
|
|
171
|
+
if (refusal.retryAfterSec !== undefined) {
|
|
172
|
+
void reply.header('Retry-After', String(refusal.retryAfterSec));
|
|
173
|
+
}
|
|
143
174
|
return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
|
|
144
175
|
}
|
|
145
|
-
/**
|
|
146
|
-
|
|
176
|
+
/** Read integrationId → descriptor from every registered `oauth-integration`
|
|
177
|
+
* provider, plus the addons that still owe one. */
|
|
178
|
+
async function readIntegrationRegistry(registry) {
|
|
147
179
|
const entries = registry.getCollectionEntries('oauth-integration');
|
|
148
|
-
const
|
|
180
|
+
const descriptors = new Map();
|
|
149
181
|
for (const [, provider] of entries) {
|
|
150
182
|
const descriptor = await provider.getDescriptor();
|
|
151
|
-
|
|
183
|
+
descriptors.set(descriptor.integrationId, descriptor);
|
|
152
184
|
}
|
|
153
|
-
|
|
185
|
+
const pendingAddons = registry
|
|
186
|
+
.getManifestDeclarers('oauth-integration')
|
|
187
|
+
.filter((addonId) => !registry.hasProvider('oauth-integration', addonId));
|
|
188
|
+
return { descriptors, pendingAddons };
|
|
154
189
|
}
|
|
155
190
|
/** Parse an application/x-www-form-urlencoded body string into a plain object. */
|
|
156
191
|
function parseFormBody(raw) {
|
|
@@ -199,13 +234,13 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
199
234
|
if (!registry) {
|
|
200
235
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
201
236
|
}
|
|
202
|
-
const
|
|
237
|
+
const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
|
|
203
238
|
const query = request.query;
|
|
204
|
-
const v = validateAuthorizeQuery(query,
|
|
239
|
+
const v = validateAuthorizeQuery(query, descriptors, pendingAddons);
|
|
205
240
|
if (!v.ok) {
|
|
206
241
|
return refuseAuthorize(reply, deps.logger, 'GET', v);
|
|
207
242
|
}
|
|
208
|
-
const descriptor =
|
|
243
|
+
const descriptor = descriptors.get(v.integration);
|
|
209
244
|
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
210
245
|
deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
|
|
211
246
|
meta: {
|
|
@@ -247,18 +282,31 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
247
282
|
// from one that is refusing it, or the client dead-ends on a 400 with no way
|
|
248
283
|
// to fall back. Unauthenticated (so is /token) and it discloses only which
|
|
249
284
|
// integrations are installed.
|
|
285
|
+
//
|
|
286
|
+
// `complete` exists because absence-means-old-hub is a STICKY verdict: a
|
|
287
|
+
// client that probes 200ms after a restart sees a short list, concludes the
|
|
288
|
+
// hub cannot do OAuth, and settles into its password fallback for good. The
|
|
289
|
+
// flag says whether the list is final; a client SHOULD re-probe while it is
|
|
290
|
+
// false rather than downgrade. The pending addon IDS are deliberately not
|
|
291
|
+
// here — this route needs no token, and "not final yet" is the entire signal
|
|
292
|
+
// a client can act on. They are on the session-gated /authorize refusal,
|
|
293
|
+
// where a human is reading them as a diagnosis.
|
|
250
294
|
fastify.get('/api/oauth2/integrations', async (_request, reply) => {
|
|
251
295
|
const registry = deps.getRegistry();
|
|
252
296
|
if (!registry) {
|
|
253
297
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
254
298
|
}
|
|
255
|
-
const
|
|
299
|
+
const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
|
|
300
|
+
if (pendingAddons.length > 0) {
|
|
301
|
+
void reply.header('Retry-After', String(INTEGRATION_PENDING_RETRY_AFTER_SEC));
|
|
302
|
+
}
|
|
256
303
|
return reply.send({
|
|
257
|
-
integrations: [...
|
|
304
|
+
integrations: [...descriptors.values()].map((d) => ({
|
|
258
305
|
integrationId: d.integrationId,
|
|
259
306
|
displayName: d.displayName,
|
|
260
307
|
requiresPkce: d.requiresPkce === true,
|
|
261
308
|
})),
|
|
309
|
+
complete: pendingAddons.length === 0,
|
|
262
310
|
});
|
|
263
311
|
});
|
|
264
312
|
// ─── POST /api/oauth2/authorize ───────────────────────────────────────────
|
|
@@ -284,7 +332,7 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
284
332
|
if (!registry) {
|
|
285
333
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
286
334
|
}
|
|
287
|
-
const
|
|
335
|
+
const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
|
|
288
336
|
const body = request.body;
|
|
289
337
|
const formQuery = {
|
|
290
338
|
response_type: body.response_type,
|
|
@@ -296,11 +344,11 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
296
344
|
};
|
|
297
345
|
// Re-validated, not trusted: the hidden fields came back from a browser
|
|
298
346
|
// and every one of them is attacker-editable.
|
|
299
|
-
const v = validateAuthorizeQuery(formQuery,
|
|
347
|
+
const v = validateAuthorizeQuery(formQuery, descriptors, pendingAddons);
|
|
300
348
|
if (!v.ok) {
|
|
301
349
|
return refuseAuthorize(reply, deps.logger, 'POST', v);
|
|
302
350
|
}
|
|
303
|
-
const descriptor =
|
|
351
|
+
const descriptor = descriptors.get(v.integration);
|
|
304
352
|
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
305
353
|
deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
|
|
306
354
|
meta: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.83",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
"@camstack/addon-decoder-nodeav": "1.2.9",
|
|
40
40
|
"@camstack/addon-notifiers": "1.2.13",
|
|
41
41
|
"@camstack/addon-pipeline": "1.2.52",
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.34",
|
|
43
43
|
"@camstack/addon-post-analysis": "1.2.56",
|
|
44
44
|
"@camstack/sdk": "1.2.11",
|
|
45
45
|
"@camstack/shm-ring": "1.1.9",
|
|
46
|
-
"@camstack/system": "1.2.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
46
|
+
"@camstack/system": "1.2.70",
|
|
47
|
+
"@camstack/types": "1.2.54",
|
|
48
48
|
"@camstack/ui-library": "1.2.38",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|