@clearly-hub/node 0.3.0 → 0.4.1
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/README.md +44 -10
- package/dist/index.cjs +204 -69
- package/dist/index.d.cts +25 -19
- package/dist/index.d.ts +25 -19
- package/dist/index.js +204 -69
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Connect your web app to Clearly.HUB with a script, some config and one server handler. You get
|
|
4
4
|
"Sign in with Clearly.HUB", the hosted screens (choose a plan, pick data, save a result, choose a
|
|
5
|
-
hub) and each hub's subscription to your app.
|
|
5
|
+
hub) and each hub's subscription to your app. The refresh token stays on your server; the page gets
|
|
6
|
+
the access token only when it asks, for protected data.
|
|
6
7
|
|
|
7
8
|
> **Preview (0.x).** The API may still change between minor versions. Proprietary: see
|
|
8
9
|
> [LICENSE](LICENSE).
|
|
@@ -30,7 +31,7 @@ const hub = clearlyHub({
|
|
|
30
31
|
m2mClientId: process.env.CLEARLY_HUB_M2M_CLIENT_ID,
|
|
31
32
|
m2mClientSecret: process.env.CLEARLY_HUB_M2M_CLIENT_SECRET,
|
|
32
33
|
appUrl: 'https://app.example.com',
|
|
33
|
-
secret: process.env.SESSION_SECRET, // 32+ characters
|
|
34
|
+
secret: process.env.SESSION_SECRET, // 32+ random characters
|
|
34
35
|
});
|
|
35
36
|
|
|
36
37
|
const app = express();
|
|
@@ -51,7 +52,9 @@ Hono, Bun or Deno. Mount it at `/clearly-hub/*`.
|
|
|
51
52
|
|
|
52
53
|
<script type="module">
|
|
53
54
|
const session = await clearlyHub.session(); // { signedIn, user }
|
|
54
|
-
window.addEventListener('clearly-hub-answer', (event) =>
|
|
55
|
+
window.addEventListener('clearly-hub-answer', (event) =>
|
|
56
|
+
console.log(event.detail),
|
|
57
|
+
);
|
|
55
58
|
</script>
|
|
56
59
|
```
|
|
57
60
|
|
|
@@ -65,6 +68,7 @@ Or `npm install @clearly-hub/browser` and `import { clearlyHub } from '@clearly-
|
|
|
65
68
|
| `clearlyHub.signIn()`, `signOut()` | Go through Clearly.HUB and come back to this page |
|
|
66
69
|
| `clearlyHub.open(action, { data, display })` | Opens a hosted screen; resolves with its checked answer. Call it from a click: browsers only allow popups there |
|
|
67
70
|
| `clearlyHub.result()` | After a redirect screen: its answer, on the page it came back to |
|
|
71
|
+
| `clearlyHub.getToken()` | The person's key for protected and paid data, or `null` when signed out. See [Protected data](#protected-data) |
|
|
68
72
|
| `clearlyHub.onAnswer(callback)` | Every answer, once each, from a popup or after a redirect; also when you start listening after it arrived (React). Returns a function that stops listening |
|
|
69
73
|
| `<clearly-hub-button action display data label>` | Branded button for `sign-in`, `sign-out` and each screen |
|
|
70
74
|
| `clearly-hub-answer` event | Every answer, from a popup or after a redirect |
|
|
@@ -94,15 +98,34 @@ blocked popup falls back to a redirect.
|
|
|
94
98
|
| `REGISTER_RESOURCE` | `{ url, format }` of your result, at a public https URL | `activeHub`, `dataset`, `resource` |
|
|
95
99
|
| `SELECT_HUB` | | `hub` (`_id`, `name`) |
|
|
96
100
|
|
|
101
|
+
## Protected data
|
|
102
|
+
|
|
103
|
+
A protected or paid resource from `DATA_PICKER` (`requiresToken: true`) has a `url` on Clearly.HUB,
|
|
104
|
+
which checks the person's key on every request. Send it as the `Authorization` header. In the page:
|
|
105
|
+
|
|
106
|
+
```js
|
|
107
|
+
const response = await fetch(resource.url, {
|
|
108
|
+
headers: { Authorization: await clearlyHub.getToken() },
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Map layers (WMS, WMTS, tiles) load many URLs themselves: use a Leaflet, OpenLayers or Cesium option
|
|
113
|
+
or plugin that adds headers to each request. The key lasts an hour; `getToken()` gets a new one in
|
|
114
|
+
time, so call it again rather than keeping the value. On your server, `hub.fetchAsUser(req, url)`
|
|
115
|
+
does the same.
|
|
116
|
+
|
|
117
|
+
The key is the person's own Clearly.HUB key: send it only to resource URLs from `DATA_PICKER` (Clearly.HUB's
|
|
118
|
+
data proxy), and keep scripts you don't trust off pages that use it. `fetchAsUser` refuses other URLs.
|
|
119
|
+
|
|
97
120
|
## The Node package
|
|
98
121
|
|
|
99
122
|
| | |
|
|
100
123
|
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
-
| `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result`
|
|
124
|
+
| `hub.express()`, `hub.handler` | The routes: `/sign-in`, `/callback`, `/sign-out`, `/session`, `/open`, `/answer`, `/result`, `/token` |
|
|
102
125
|
| `onSignIn(person, { request, req })` | Called after each sign-in. Return your own user id to keep it in the session, or `{ appUserId, redirectTo }` to also choose the next page |
|
|
103
126
|
| `onAnswer(result, { user, data, request, req })` | Called with each checked answer before it reaches the browser, for example to set up a workspace. `data` is what the screen was sent; what you return reaches the page as `result.app` |
|
|
104
127
|
| `hub.getUser(req)` | Who's signed in, in your own routes |
|
|
105
|
-
| `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) with the person's key
|
|
128
|
+
| `hub.fetchAsUser(req, url)` | Fetch protected or paid data (from `DATA_PICKER`) on your server, with the person's key. Only URLs on Clearly.HUB's data proxy |
|
|
106
129
|
| `hub.getSubscription(hubId)`, `getSubscriptions()` | A hub's subscription to your app, or all of them |
|
|
107
130
|
| `hub.reportUsage({ request or sub, hubId, quantity })` | Report billable use; Clearly.HUB bills the hub. Pass `sub` for a Clearly.HUB account you stored, without a live sign-in |
|
|
108
131
|
| `hub.getUserToken(req)`, `getAppToken()` | The person's key and your app's key, for calls of your own |
|
|
@@ -121,18 +144,29 @@ const hub = clearlyHub<express.Request>({
|
|
|
121
144
|
});
|
|
122
145
|
```
|
|
123
146
|
|
|
124
|
-
**What it checks for you:** every screen answer's Ed25519 signature,
|
|
125
|
-
opened in this browser, once (`state`)
|
|
126
|
-
|
|
147
|
+
**What it checks for you:** every screen answer's Ed25519 signature, that it answers a screen
|
|
148
|
+
opened in this browser, once (`state`), and that it was given to your app and that screen
|
|
149
|
+
(`client_id`, callback URL, action).
|
|
150
|
+
|
|
151
|
+
**What to trust in an answer:** it's a snapshot of the moment the person clicked. For access later,
|
|
152
|
+
ask `getSubscription(hubId)` when it's used, not the `status` you got then. Ignore `answer.user`:
|
|
153
|
+
Clearly.HUB doesn't check it yet. Who's signed in is the session's user.
|
|
154
|
+
|
|
155
|
+
The session is encrypted in cookies, so there's nothing to store; it's split over several cookies
|
|
156
|
+
because tokens are large. A copied cookie stays valid until its refresh token expires: signing out
|
|
157
|
+
clears only this browser's copy.
|
|
127
158
|
|
|
128
|
-
**Local Clearly.HUB:** pass `urls: { screens, api,
|
|
159
|
+
**Local Clearly.HUB:** pass `urls: { screens, api, proxy, auth, issuer }`; see
|
|
129
160
|
`examples/express/.env.example`.
|
|
130
161
|
|
|
131
162
|
## Not covered (yet)
|
|
132
163
|
|
|
133
164
|
- Setting up your own workspaces or tenants: do it by hand, with plans that need approval, or in
|
|
134
165
|
`onAnswer` for `SELECT_SUBSCRIPTION`.
|
|
135
|
-
- Other server languages:
|
|
166
|
+
- Other server languages (.NET, Python, ...): the browser package works with any
|
|
167
|
+
backend that answers the same routes. Contact us at
|
|
168
|
+
[support@futureinsight.nl](mailto:support@futureinsight.nl) and we'll help
|
|
169
|
+
you with yours.
|
|
136
170
|
|
|
137
171
|
## Working on this repo
|
|
138
172
|
|
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,23 @@ __export(index_exports, {
|
|
|
34
34
|
});
|
|
35
35
|
module.exports = __toCommonJS(index_exports);
|
|
36
36
|
|
|
37
|
+
// ../shared/src/protocol.ts
|
|
38
|
+
var DEFAULT_BASE_PATH = "/clearly-hub";
|
|
39
|
+
var DEFAULT_HUB_URL = "https://hub.clearly.app";
|
|
40
|
+
var DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
41
|
+
var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
42
|
+
var ROUTES = {
|
|
43
|
+
signIn: "/sign-in",
|
|
44
|
+
callback: "/callback",
|
|
45
|
+
signOut: "/sign-out",
|
|
46
|
+
session: "/session",
|
|
47
|
+
open: "/open",
|
|
48
|
+
answer: "/answer",
|
|
49
|
+
result: "/result",
|
|
50
|
+
token: "/token"
|
|
51
|
+
};
|
|
52
|
+
var ANSWER_CHANNEL = "clearly-hub-answers";
|
|
53
|
+
|
|
37
54
|
// ../shared/src/screens.ts
|
|
38
55
|
var SCREEN_ACTIONS = [
|
|
39
56
|
"SELECT_SUBSCRIPTION",
|
|
@@ -62,22 +79,6 @@ function checkScreenData(action, data) {
|
|
|
62
79
|
return void 0;
|
|
63
80
|
}
|
|
64
81
|
|
|
65
|
-
// ../shared/src/protocol.ts
|
|
66
|
-
var DEFAULT_BASE_PATH = "/clearly-hub";
|
|
67
|
-
var DEFAULT_HUB_URL = "https://hub.clearly.app";
|
|
68
|
-
var DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
69
|
-
var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
70
|
-
var ROUTES = {
|
|
71
|
-
signIn: "/sign-in",
|
|
72
|
-
callback: "/callback",
|
|
73
|
-
signOut: "/sign-out",
|
|
74
|
-
session: "/session",
|
|
75
|
-
open: "/open",
|
|
76
|
-
answer: "/answer",
|
|
77
|
-
result: "/result"
|
|
78
|
-
};
|
|
79
|
-
var ANSWER_CHANNEL = "clearly-hub-answers";
|
|
80
|
-
|
|
81
82
|
// src/config.ts
|
|
82
83
|
var trimSlash = (url) => url.replace(/\/+$/, "");
|
|
83
84
|
function resolveConfig(options) {
|
|
@@ -89,6 +90,7 @@ function resolveConfig(options) {
|
|
|
89
90
|
const appUrl = trimSlash(options.appUrl);
|
|
90
91
|
const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
|
|
91
92
|
const api = trimSlash(options.urls?.api ?? hubUrl);
|
|
93
|
+
const proxy = trimSlash(options.urls?.proxy ?? hubUrl);
|
|
92
94
|
return {
|
|
93
95
|
clientId: options.clientId,
|
|
94
96
|
m2mClientId: options.m2mClientId,
|
|
@@ -100,7 +102,8 @@ function resolveConfig(options) {
|
|
|
100
102
|
urls: {
|
|
101
103
|
screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
|
|
102
104
|
api,
|
|
103
|
-
|
|
105
|
+
proxy,
|
|
106
|
+
usage: options.urls?.usage ?? `${proxy}/register-usage`,
|
|
104
107
|
publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
|
|
105
108
|
auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
|
|
106
109
|
issuer: options.urls?.issuer ?? DEFAULT_ISSUER
|
|
@@ -120,11 +123,15 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
120
123
|
try {
|
|
121
124
|
const headers = new Headers();
|
|
122
125
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
123
|
-
if (Array.isArray(value))
|
|
126
|
+
if (Array.isArray(value))
|
|
127
|
+
value.forEach((item) => headers.append(name, item));
|
|
124
128
|
else if (value !== void 0) headers.set(name, value);
|
|
125
129
|
}
|
|
126
130
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
127
|
-
const response = await handle(
|
|
131
|
+
const response = await handle(
|
|
132
|
+
new Request(url, { method: req.method, headers }),
|
|
133
|
+
req
|
|
134
|
+
);
|
|
128
135
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
129
136
|
next();
|
|
130
137
|
return;
|
|
@@ -178,16 +185,24 @@ var Screens = class {
|
|
|
178
185
|
base64Decode(signature),
|
|
179
186
|
new TextEncoder().encode(data)
|
|
180
187
|
);
|
|
181
|
-
if (!isSigned)
|
|
182
|
-
|
|
183
|
-
|
|
188
|
+
if (!isSigned)
|
|
189
|
+
throw new AnswerError("This answer was not signed by Clearly.HUB");
|
|
190
|
+
const { request, response } = JSON.parse(
|
|
191
|
+
new TextDecoder().decode(base64Decode(data))
|
|
192
|
+
);
|
|
193
|
+
return { request: request ?? {}, response };
|
|
184
194
|
}
|
|
185
195
|
// Fetched once; a key change needs a restart
|
|
186
196
|
getPublicKey() {
|
|
187
197
|
this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
|
|
188
|
-
if (!response.ok)
|
|
198
|
+
if (!response.ok)
|
|
199
|
+
throw new Error(`No public key at ${this.config.urls.publicKey}`);
|
|
189
200
|
return response.text();
|
|
190
|
-
}).then(
|
|
201
|
+
}).then(
|
|
202
|
+
(pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, [
|
|
203
|
+
"verify"
|
|
204
|
+
])
|
|
205
|
+
).catch((error) => {
|
|
191
206
|
this.publicKey = void 0;
|
|
192
207
|
throw error;
|
|
193
208
|
});
|
|
@@ -198,7 +213,8 @@ var AnswerError = class extends Error {
|
|
|
198
213
|
};
|
|
199
214
|
function toScreenResult(action, response) {
|
|
200
215
|
const { result, ...answer } = response;
|
|
201
|
-
if (result === "SUCCESS")
|
|
216
|
+
if (result === "SUCCESS")
|
|
217
|
+
return { action, result: "SUCCESS", answer };
|
|
202
218
|
return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
|
|
203
219
|
}
|
|
204
220
|
function base64Encode(text) {
|
|
@@ -214,7 +230,11 @@ function readRawQuery(url) {
|
|
|
214
230
|
const params = /* @__PURE__ */ new Map();
|
|
215
231
|
for (const pair of url.search.slice(1).split("&")) {
|
|
216
232
|
const index = pair.indexOf("=");
|
|
217
|
-
if (index > 0)
|
|
233
|
+
if (index > 0)
|
|
234
|
+
params.set(
|
|
235
|
+
pair.slice(0, index),
|
|
236
|
+
decodeURIComponent(pair.slice(index + 1))
|
|
237
|
+
);
|
|
218
238
|
}
|
|
219
239
|
return params;
|
|
220
240
|
}
|
|
@@ -228,7 +248,8 @@ function createHandler(parts) {
|
|
|
228
248
|
const url = new URL(request.url);
|
|
229
249
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
230
250
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
231
|
-
if (request.method !== "GET")
|
|
251
|
+
if (request.method !== "GET")
|
|
252
|
+
return new Response("Method not allowed", { status: 405 });
|
|
232
253
|
const host = request.headers.get("host");
|
|
233
254
|
const appHost = new URL(config.appUrl).host;
|
|
234
255
|
if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
|
|
@@ -241,8 +262,12 @@ function createHandler(parts) {
|
|
|
241
262
|
response = await routeRequest(route, url, data, parts, { request, req });
|
|
242
263
|
} catch (error) {
|
|
243
264
|
const isRefusedAnswer = error instanceof AnswerError;
|
|
244
|
-
if (!isRefusedAnswer)
|
|
245
|
-
|
|
265
|
+
if (!isRefusedAnswer)
|
|
266
|
+
console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
267
|
+
response = isRefusedAnswer ? errorPage(400, error.message) : errorPage(
|
|
268
|
+
500,
|
|
269
|
+
config.isSecure ? "Something went wrong with Clearly.HUB. Please try again." : describeError(error)
|
|
270
|
+
);
|
|
246
271
|
}
|
|
247
272
|
if (JSON.stringify(data) !== before) {
|
|
248
273
|
for (const cookie of await sessions.write(data, chunkCount)) {
|
|
@@ -259,7 +284,8 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
259
284
|
case ROUTES.callback:
|
|
260
285
|
return finishSignIn(url, session, parts, incoming);
|
|
261
286
|
case ROUTES.signOut:
|
|
262
|
-
for (const key of Object.keys(session))
|
|
287
|
+
for (const key of Object.keys(session))
|
|
288
|
+
delete session[key];
|
|
263
289
|
return redirect(parts.signIn.signOutUrl());
|
|
264
290
|
case ROUTES.session:
|
|
265
291
|
return json(await readSession(session, parts));
|
|
@@ -272,12 +298,16 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
272
298
|
delete session.result;
|
|
273
299
|
return json(result);
|
|
274
300
|
}
|
|
301
|
+
case ROUTES.token:
|
|
302
|
+
return readToken(session, parts);
|
|
275
303
|
default:
|
|
276
304
|
return notFound();
|
|
277
305
|
}
|
|
278
306
|
}
|
|
279
307
|
async function startSignIn(url, session, { config, signIn }) {
|
|
280
|
-
const { url: authorizationUrl, pending } = await signIn.start(
|
|
308
|
+
const { url: authorizationUrl, pending } = await signIn.start(
|
|
309
|
+
safeReturnTo(url, config)
|
|
310
|
+
);
|
|
281
311
|
session.signIn = pending;
|
|
282
312
|
return redirect(authorizationUrl);
|
|
283
313
|
}
|
|
@@ -291,13 +321,28 @@ async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
|
291
321
|
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
292
322
|
session.user = { ...person, appUserId };
|
|
293
323
|
session.tokens = tokens;
|
|
294
|
-
return redirect(
|
|
324
|
+
return redirect(
|
|
325
|
+
`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`
|
|
326
|
+
);
|
|
295
327
|
}
|
|
296
328
|
async function readSession(session, parts) {
|
|
297
329
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
298
330
|
if (!session.user || !accessToken) return { signedIn: false };
|
|
299
331
|
return { signedIn: true, user: session.user };
|
|
300
332
|
}
|
|
333
|
+
async function readToken(session, parts) {
|
|
334
|
+
const accessToken = await getFreshAccessToken(session, parts);
|
|
335
|
+
if (!session.user || !accessToken)
|
|
336
|
+
return json({ error: "Nobody is signed in with Clearly.HUB" }, 401);
|
|
337
|
+
const token = {
|
|
338
|
+
accessToken,
|
|
339
|
+
expiresIn: Math.max(
|
|
340
|
+
0,
|
|
341
|
+
session.tokens.expiresAt - Math.floor(Date.now() / 1e3)
|
|
342
|
+
)
|
|
343
|
+
};
|
|
344
|
+
return json(token);
|
|
345
|
+
}
|
|
301
346
|
async function getFreshAccessToken(session, { signIn }) {
|
|
302
347
|
const tokens = session.tokens;
|
|
303
348
|
if (!tokens) return void 0;
|
|
@@ -314,7 +359,8 @@ async function getFreshAccessToken(session, { signIn }) {
|
|
|
314
359
|
}
|
|
315
360
|
function openScreen(url, session, { config, screens }) {
|
|
316
361
|
const action = url.searchParams.get("action");
|
|
317
|
-
if (!isScreenAction(action))
|
|
362
|
+
if (!isScreenAction(action))
|
|
363
|
+
return errorPage(400, `Unknown screen: ${action}`);
|
|
318
364
|
const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
|
|
319
365
|
const dataParam = url.searchParams.get("data");
|
|
320
366
|
let data;
|
|
@@ -339,15 +385,21 @@ function openScreen(url, session, { config, screens }) {
|
|
|
339
385
|
}
|
|
340
386
|
async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
341
387
|
const query = readRawQuery(url);
|
|
342
|
-
const {
|
|
388
|
+
const { request, response } = await screens.readAnswer(
|
|
343
389
|
query.get("data") ?? "",
|
|
344
390
|
query.get("signature") ?? ""
|
|
345
391
|
);
|
|
392
|
+
const state = request.state;
|
|
346
393
|
const pending = state ? session.screens?.[state] : void 0;
|
|
347
394
|
if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
|
|
348
|
-
throw new AnswerError(
|
|
395
|
+
throw new AnswerError(
|
|
396
|
+
"This answer does not belong to a screen opened in this browser"
|
|
397
|
+
);
|
|
349
398
|
}
|
|
350
399
|
delete session.screens[state];
|
|
400
|
+
if (request.client_id !== config.clientId || request.redirect_url !== screens.answerUrl || request.actions?.length !== 1 || request.actions[0] !== pending.action) {
|
|
401
|
+
throw new AnswerError("This answer was given to another app or screen");
|
|
402
|
+
}
|
|
351
403
|
let result = toScreenResult(pending.action, response);
|
|
352
404
|
try {
|
|
353
405
|
const app = await config.onAnswer?.(result, {
|
|
@@ -357,7 +409,11 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
|
357
409
|
});
|
|
358
410
|
if (app !== void 0) result = { ...result, app };
|
|
359
411
|
} catch (error) {
|
|
360
|
-
result = {
|
|
412
|
+
result = {
|
|
413
|
+
action: pending.action,
|
|
414
|
+
result: "FAILURE",
|
|
415
|
+
error: error.message
|
|
416
|
+
};
|
|
361
417
|
}
|
|
362
418
|
if (pending.display === "redirect") {
|
|
363
419
|
session.result = { ref: pending.ref, result };
|
|
@@ -392,11 +448,21 @@ function safePath(path, config) {
|
|
|
392
448
|
}
|
|
393
449
|
}
|
|
394
450
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
395
|
-
var json = (body) => new Response(JSON.stringify(body), {
|
|
396
|
-
|
|
451
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
452
|
+
status,
|
|
453
|
+
headers: {
|
|
454
|
+
"Content-Type": "application/json",
|
|
455
|
+
"Cache-Control": "no-store"
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
var html = (body, status = 200) => new Response(body, {
|
|
459
|
+
status,
|
|
460
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
461
|
+
});
|
|
462
|
+
var notFound = () => new Response("Not found", {
|
|
463
|
+
status: 404,
|
|
464
|
+
headers: { "X-Clearly-Hub": "unhandled" }
|
|
397
465
|
});
|
|
398
|
-
var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
399
|
-
var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
|
|
400
466
|
function describeError(error) {
|
|
401
467
|
const messages = [];
|
|
402
468
|
for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
|
|
@@ -405,7 +471,10 @@ function describeError(error) {
|
|
|
405
471
|
return messages.join(": ") || String(error);
|
|
406
472
|
}
|
|
407
473
|
function errorPage(status, message) {
|
|
408
|
-
const escaped = message.replace(
|
|
474
|
+
const escaped = message.replace(
|
|
475
|
+
/[&<>"]/g,
|
|
476
|
+
(char) => `&#${char.charCodeAt(0)};`
|
|
477
|
+
);
|
|
409
478
|
return html(
|
|
410
479
|
`<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
|
|
411
480
|
status
|
|
@@ -427,7 +496,9 @@ var HubApi = class {
|
|
|
427
496
|
try {
|
|
428
497
|
return await this.config.fetch(url, init);
|
|
429
498
|
} catch (error) {
|
|
430
|
-
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
499
|
+
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
500
|
+
cause: error
|
|
501
|
+
});
|
|
431
502
|
}
|
|
432
503
|
}
|
|
433
504
|
get hasAppKey() {
|
|
@@ -437,11 +508,16 @@ var HubApi = class {
|
|
|
437
508
|
// until shortly before it expires
|
|
438
509
|
async getAppToken() {
|
|
439
510
|
const now = Date.now() / 1e3;
|
|
440
|
-
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
511
|
+
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
512
|
+
return this.appToken.value;
|
|
441
513
|
if (!this.hasAppKey) {
|
|
442
|
-
throw new Error(
|
|
514
|
+
throw new Error(
|
|
515
|
+
"Clearly.HUB: m2mClientId and m2mClientSecret are needed for this"
|
|
516
|
+
);
|
|
443
517
|
}
|
|
444
|
-
const credentials = btoa(
|
|
518
|
+
const credentials = btoa(
|
|
519
|
+
`${this.config.m2mClientId}:${this.config.m2mClientSecret}`
|
|
520
|
+
);
|
|
445
521
|
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
446
522
|
method: "POST",
|
|
447
523
|
headers: {
|
|
@@ -457,7 +533,10 @@ var HubApi = class {
|
|
|
457
533
|
throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
|
|
458
534
|
}
|
|
459
535
|
const body = await response.json();
|
|
460
|
-
this.appToken = {
|
|
536
|
+
this.appToken = {
|
|
537
|
+
value: body.access_token,
|
|
538
|
+
expiresAt: now + body.expires_in
|
|
539
|
+
};
|
|
461
540
|
return body.access_token;
|
|
462
541
|
}
|
|
463
542
|
// Every hub that bought your app, with status and your plan id
|
|
@@ -465,11 +544,16 @@ var HubApi = class {
|
|
|
465
544
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
466
545
|
return this.subscriptions.value;
|
|
467
546
|
}
|
|
468
|
-
const response = await this.call(
|
|
469
|
-
|
|
470
|
-
|
|
547
|
+
const response = await this.call(
|
|
548
|
+
`${this.config.urls.api}/api/app-api/subscriptions`,
|
|
549
|
+
{
|
|
550
|
+
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
551
|
+
}
|
|
552
|
+
);
|
|
471
553
|
if (!response.ok) {
|
|
472
|
-
throw new Error(
|
|
554
|
+
throw new Error(
|
|
555
|
+
`Could not read the subscriptions from Clearly.HUB (${response.status})`
|
|
556
|
+
);
|
|
473
557
|
}
|
|
474
558
|
const items = await response.json();
|
|
475
559
|
const value = items.map((item) => ({
|
|
@@ -502,12 +586,16 @@ var HubApi = class {
|
|
|
502
586
|
});
|
|
503
587
|
if (!response.ok) {
|
|
504
588
|
const body = await response.json().catch(() => ({}));
|
|
505
|
-
throw new Error(
|
|
589
|
+
throw new Error(
|
|
590
|
+
`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`
|
|
591
|
+
);
|
|
506
592
|
}
|
|
507
593
|
}
|
|
508
594
|
};
|
|
509
595
|
function pickSubscription(subscriptions, hubId) {
|
|
510
|
-
const ofHub = subscriptions.filter(
|
|
596
|
+
const ofHub = subscriptions.filter(
|
|
597
|
+
(subscription) => subscription.hubId === hubId
|
|
598
|
+
);
|
|
511
599
|
return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
|
|
512
600
|
}
|
|
513
601
|
|
|
@@ -524,7 +612,12 @@ var SignIn = class {
|
|
|
524
612
|
}
|
|
525
613
|
// Discovery happens once, on the first sign-in
|
|
526
614
|
getConfiguration() {
|
|
527
|
-
this.configuration ??= oidc.discovery(
|
|
615
|
+
this.configuration ??= oidc.discovery(
|
|
616
|
+
new URL(this.config.urls.issuer),
|
|
617
|
+
this.config.clientId,
|
|
618
|
+
void 0,
|
|
619
|
+
oidc.None()
|
|
620
|
+
).catch((error) => {
|
|
528
621
|
this.configuration = void 0;
|
|
529
622
|
throw error;
|
|
530
623
|
});
|
|
@@ -546,11 +639,15 @@ var SignIn = class {
|
|
|
546
639
|
return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
|
|
547
640
|
}
|
|
548
641
|
async finish(currentUrl, pending) {
|
|
549
|
-
const tokens = await oidc.authorizationCodeGrant(
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
642
|
+
const tokens = await oidc.authorizationCodeGrant(
|
|
643
|
+
await this.getConfiguration(),
|
|
644
|
+
currentUrl,
|
|
645
|
+
{
|
|
646
|
+
pkceCodeVerifier: pending.codeVerifier,
|
|
647
|
+
expectedState: pending.state,
|
|
648
|
+
expectedNonce: pending.nonce
|
|
649
|
+
}
|
|
650
|
+
);
|
|
554
651
|
const claims = tokens.claims();
|
|
555
652
|
if (!claims) throw new Error("Clearly.HUB sent no ID token");
|
|
556
653
|
return {
|
|
@@ -563,7 +660,10 @@ var SignIn = class {
|
|
|
563
660
|
};
|
|
564
661
|
}
|
|
565
662
|
async refresh(refreshToken) {
|
|
566
|
-
const tokens = await oidc.refreshTokenGrant(
|
|
663
|
+
const tokens = await oidc.refreshTokenGrant(
|
|
664
|
+
await this.getConfiguration(),
|
|
665
|
+
refreshToken
|
|
666
|
+
);
|
|
567
667
|
return toSessionTokens(tokens, refreshToken);
|
|
568
668
|
}
|
|
569
669
|
// Signing out of Clearly.HUB too, or the next sign-in goes straight through
|
|
@@ -596,12 +696,16 @@ function parseCookies(header) {
|
|
|
596
696
|
const cookies = /* @__PURE__ */ new Map();
|
|
597
697
|
for (const part of header.split(";")) {
|
|
598
698
|
const index = part.indexOf("=");
|
|
599
|
-
if (index > 0)
|
|
699
|
+
if (index > 0)
|
|
700
|
+
cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
|
|
600
701
|
}
|
|
601
702
|
return cookies;
|
|
602
703
|
}
|
|
603
704
|
async function deriveKey(secret) {
|
|
604
|
-
const digest = await crypto.subtle.digest(
|
|
705
|
+
const digest = await crypto.subtle.digest(
|
|
706
|
+
"SHA-256",
|
|
707
|
+
new TextEncoder().encode(secret)
|
|
708
|
+
);
|
|
605
709
|
return new Uint8Array(digest);
|
|
606
710
|
}
|
|
607
711
|
var SessionStore = class {
|
|
@@ -619,8 +723,14 @@ var SessionStore = class {
|
|
|
619
723
|
}
|
|
620
724
|
if (!chunks.length) return { data: {}, chunkCount: 0 };
|
|
621
725
|
try {
|
|
622
|
-
const { plaintext } = await (0, import_jose.compactDecrypt)(
|
|
623
|
-
|
|
726
|
+
const { plaintext } = await (0, import_jose.compactDecrypt)(
|
|
727
|
+
chunks.join(""),
|
|
728
|
+
await this.keyPromise
|
|
729
|
+
);
|
|
730
|
+
return {
|
|
731
|
+
data: JSON.parse(new TextDecoder().decode(plaintext)),
|
|
732
|
+
chunkCount: chunks.length
|
|
733
|
+
};
|
|
624
734
|
} catch {
|
|
625
735
|
return { data: {}, chunkCount: chunks.length };
|
|
626
736
|
}
|
|
@@ -628,7 +738,9 @@ var SessionStore = class {
|
|
|
628
738
|
// Set-Cookie headers for the new session, clearing chunks it no longer needs
|
|
629
739
|
async write(data, previousChunkCount) {
|
|
630
740
|
const isEmpty = Object.keys(data).length === 0;
|
|
631
|
-
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
741
|
+
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
742
|
+
new RegExp(`.{1,${CHUNK_SIZE}}`, "g")
|
|
743
|
+
);
|
|
632
744
|
const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
|
|
633
745
|
const headers = chunks.map(
|
|
634
746
|
(chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
|
|
@@ -675,10 +787,17 @@ function clearlyHub(options) {
|
|
|
675
787
|
},
|
|
676
788
|
getUserToken,
|
|
677
789
|
// Fetch protected or paid data (for example from DATA_PICKER) with the
|
|
678
|
-
// person's key. Open data works without it.
|
|
790
|
+
// person's key. Open data works without it. Only URLs on Clearly.HUB's
|
|
791
|
+
// data proxy: the key would let anyone else act as this person
|
|
679
792
|
async fetchAsUser(request, url, init = {}) {
|
|
793
|
+
if (!isOnProxy(url, config.urls.proxy)) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
`Clearly.HUB: fetchAsUser only sends the person's key to Clearly.HUB's data proxy (${config.urls.proxy}, set with urls.proxy), not to ${url}`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
680
798
|
const token = await getUserToken(request);
|
|
681
|
-
if (!token)
|
|
799
|
+
if (!token)
|
|
800
|
+
throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
|
|
682
801
|
const headers = new Headers(init.headers);
|
|
683
802
|
headers.set("Authorization", token);
|
|
684
803
|
return config.fetch(url, { ...init, headers });
|
|
@@ -703,11 +822,18 @@ function clearlyHub(options) {
|
|
|
703
822
|
}) {
|
|
704
823
|
const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
|
|
705
824
|
if (!person) {
|
|
706
|
-
throw new Error(
|
|
825
|
+
throw new Error(
|
|
826
|
+
"Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account"
|
|
827
|
+
);
|
|
707
828
|
}
|
|
708
|
-
const subscription = pickSubscription(
|
|
829
|
+
const subscription = pickSubscription(
|
|
830
|
+
await parts.api.getSubscriptions(),
|
|
831
|
+
hubId
|
|
832
|
+
);
|
|
709
833
|
if (subscription?.status !== "ACTIVE") {
|
|
710
|
-
throw new Error(
|
|
834
|
+
throw new Error(
|
|
835
|
+
`Clearly.HUB: hub ${hubId} has no active subscription to your app`
|
|
836
|
+
);
|
|
711
837
|
}
|
|
712
838
|
await parts.api.reportUsage({
|
|
713
839
|
sub: person,
|
|
@@ -718,6 +844,15 @@ function clearlyHub(options) {
|
|
|
718
844
|
}
|
|
719
845
|
};
|
|
720
846
|
}
|
|
847
|
+
function isOnProxy(url, proxy) {
|
|
848
|
+
try {
|
|
849
|
+
const target = new URL(url);
|
|
850
|
+
const base = new URL(`${proxy}/`);
|
|
851
|
+
return target.origin === base.origin && target.pathname.startsWith(base.pathname);
|
|
852
|
+
} catch {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
721
856
|
// Annotate the CommonJS export names for ESM import in node:
|
|
722
857
|
0 && (module.exports = {
|
|
723
858
|
clearlyHub
|
package/dist/index.d.cts
CHANGED
|
@@ -71,6 +71,26 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
|
|
|
71
71
|
app?: unknown;
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
declare const DEFAULT_BASE_PATH = "/clearly-hub";
|
|
75
|
+
declare const DEFAULT_HUB_URL = "https://hub.clearly.app";
|
|
76
|
+
declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
77
|
+
declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
78
|
+
declare const ROUTES: {
|
|
79
|
+
readonly signIn: "/sign-in";
|
|
80
|
+
readonly callback: "/callback";
|
|
81
|
+
readonly signOut: "/sign-out";
|
|
82
|
+
readonly session: "/session";
|
|
83
|
+
readonly open: "/open";
|
|
84
|
+
readonly answer: "/answer";
|
|
85
|
+
readonly result: "/result";
|
|
86
|
+
readonly token: "/token";
|
|
87
|
+
};
|
|
88
|
+
declare const ANSWER_CHANNEL = "clearly-hub-answers";
|
|
89
|
+
interface AnswerMessage {
|
|
90
|
+
ref: string;
|
|
91
|
+
result: ScreenResult;
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
interface SessionUser {
|
|
75
95
|
sub: string;
|
|
76
96
|
email: string;
|
|
@@ -89,24 +109,9 @@ type Session = {
|
|
|
89
109
|
signedIn: true;
|
|
90
110
|
user: SessionUser;
|
|
91
111
|
};
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
96
|
-
declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
97
|
-
declare const ROUTES: {
|
|
98
|
-
readonly signIn: "/sign-in";
|
|
99
|
-
readonly callback: "/callback";
|
|
100
|
-
readonly signOut: "/sign-out";
|
|
101
|
-
readonly session: "/session";
|
|
102
|
-
readonly open: "/open";
|
|
103
|
-
readonly answer: "/answer";
|
|
104
|
-
readonly result: "/result";
|
|
105
|
-
};
|
|
106
|
-
declare const ANSWER_CHANNEL = "clearly-hub-answers";
|
|
107
|
-
interface AnswerMessage {
|
|
108
|
-
ref: string;
|
|
109
|
-
result: ScreenResult;
|
|
112
|
+
interface AccessToken {
|
|
113
|
+
accessToken: string;
|
|
114
|
+
expiresIn: number;
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
interface SignedInPerson {
|
|
@@ -143,6 +148,7 @@ interface ClearlyHubOptions<Req = unknown> {
|
|
|
143
148
|
interface ClearlyHubUrls {
|
|
144
149
|
screens: string;
|
|
145
150
|
api: string;
|
|
151
|
+
proxy: string;
|
|
146
152
|
usage: string;
|
|
147
153
|
publicKey: string;
|
|
148
154
|
auth: string;
|
|
@@ -179,4 +185,4 @@ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
|
179
185
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
180
186
|
|
|
181
187
|
export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
|
|
182
|
-
export type { AnswerContext, AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HookContext, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignInDecision, SignedInPerson };
|
|
188
|
+
export type { AccessToken, AnswerContext, AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HookContext, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignInDecision, SignedInPerson };
|
package/dist/index.d.ts
CHANGED
|
@@ -71,6 +71,26 @@ type ScreenResult<A extends ScreenAction = ScreenAction> = {
|
|
|
71
71
|
app?: unknown;
|
|
72
72
|
};
|
|
73
73
|
|
|
74
|
+
declare const DEFAULT_BASE_PATH = "/clearly-hub";
|
|
75
|
+
declare const DEFAULT_HUB_URL = "https://hub.clearly.app";
|
|
76
|
+
declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
77
|
+
declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
78
|
+
declare const ROUTES: {
|
|
79
|
+
readonly signIn: "/sign-in";
|
|
80
|
+
readonly callback: "/callback";
|
|
81
|
+
readonly signOut: "/sign-out";
|
|
82
|
+
readonly session: "/session";
|
|
83
|
+
readonly open: "/open";
|
|
84
|
+
readonly answer: "/answer";
|
|
85
|
+
readonly result: "/result";
|
|
86
|
+
readonly token: "/token";
|
|
87
|
+
};
|
|
88
|
+
declare const ANSWER_CHANNEL = "clearly-hub-answers";
|
|
89
|
+
interface AnswerMessage {
|
|
90
|
+
ref: string;
|
|
91
|
+
result: ScreenResult;
|
|
92
|
+
}
|
|
93
|
+
|
|
74
94
|
interface SessionUser {
|
|
75
95
|
sub: string;
|
|
76
96
|
email: string;
|
|
@@ -89,24 +109,9 @@ type Session = {
|
|
|
89
109
|
signedIn: true;
|
|
90
110
|
user: SessionUser;
|
|
91
111
|
};
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
declare const DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
96
|
-
declare const DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
97
|
-
declare const ROUTES: {
|
|
98
|
-
readonly signIn: "/sign-in";
|
|
99
|
-
readonly callback: "/callback";
|
|
100
|
-
readonly signOut: "/sign-out";
|
|
101
|
-
readonly session: "/session";
|
|
102
|
-
readonly open: "/open";
|
|
103
|
-
readonly answer: "/answer";
|
|
104
|
-
readonly result: "/result";
|
|
105
|
-
};
|
|
106
|
-
declare const ANSWER_CHANNEL = "clearly-hub-answers";
|
|
107
|
-
interface AnswerMessage {
|
|
108
|
-
ref: string;
|
|
109
|
-
result: ScreenResult;
|
|
112
|
+
interface AccessToken {
|
|
113
|
+
accessToken: string;
|
|
114
|
+
expiresIn: number;
|
|
110
115
|
}
|
|
111
116
|
|
|
112
117
|
interface SignedInPerson {
|
|
@@ -143,6 +148,7 @@ interface ClearlyHubOptions<Req = unknown> {
|
|
|
143
148
|
interface ClearlyHubUrls {
|
|
144
149
|
screens: string;
|
|
145
150
|
api: string;
|
|
151
|
+
proxy: string;
|
|
146
152
|
usage: string;
|
|
147
153
|
publicKey: string;
|
|
148
154
|
auth: string;
|
|
@@ -179,4 +185,4 @@ declare function clearlyHub<Req = unknown>(options: ClearlyHubOptions<Req>): {
|
|
|
179
185
|
type ClearlyHub = ReturnType<typeof clearlyHub>;
|
|
180
186
|
|
|
181
187
|
export { ANSWER_CHANNEL, DEFAULT_AUTH_URL, DEFAULT_BASE_PATH, DEFAULT_HUB_URL, DEFAULT_ISSUER, ROUTES, SCREEN_ACTIONS, checkScreenData, clearlyHub, isScreenAction };
|
|
182
|
-
export type { AnswerContext, AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HookContext, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignInDecision, SignedInPerson };
|
|
188
|
+
export type { AccessToken, AnswerContext, AnswerMessage, AppSubscription, ClearlyHub, ClearlyHubOptions, ClearlyHubUrls, CookieSource, HookContext, HubRef, HubSubscription, PickedResource, ScreenAction, ScreenAnswers, ScreenData, ScreenDisplay, ScreenResult, Session, SessionUser, SignInDecision, SignedInPerson };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
// ../shared/src/protocol.ts
|
|
2
|
+
var DEFAULT_BASE_PATH = "/clearly-hub";
|
|
3
|
+
var DEFAULT_HUB_URL = "https://hub.clearly.app";
|
|
4
|
+
var DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
5
|
+
var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
6
|
+
var ROUTES = {
|
|
7
|
+
signIn: "/sign-in",
|
|
8
|
+
callback: "/callback",
|
|
9
|
+
signOut: "/sign-out",
|
|
10
|
+
session: "/session",
|
|
11
|
+
open: "/open",
|
|
12
|
+
answer: "/answer",
|
|
13
|
+
result: "/result",
|
|
14
|
+
token: "/token"
|
|
15
|
+
};
|
|
16
|
+
var ANSWER_CHANNEL = "clearly-hub-answers";
|
|
17
|
+
|
|
1
18
|
// ../shared/src/screens.ts
|
|
2
19
|
var SCREEN_ACTIONS = [
|
|
3
20
|
"SELECT_SUBSCRIPTION",
|
|
@@ -26,22 +43,6 @@ function checkScreenData(action, data) {
|
|
|
26
43
|
return void 0;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
// ../shared/src/protocol.ts
|
|
30
|
-
var DEFAULT_BASE_PATH = "/clearly-hub";
|
|
31
|
-
var DEFAULT_HUB_URL = "https://hub.clearly.app";
|
|
32
|
-
var DEFAULT_AUTH_URL = "https://auth.clearly.app";
|
|
33
|
-
var DEFAULT_ISSUER = "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_1fM1Zd9mJ";
|
|
34
|
-
var ROUTES = {
|
|
35
|
-
signIn: "/sign-in",
|
|
36
|
-
callback: "/callback",
|
|
37
|
-
signOut: "/sign-out",
|
|
38
|
-
session: "/session",
|
|
39
|
-
open: "/open",
|
|
40
|
-
answer: "/answer",
|
|
41
|
-
result: "/result"
|
|
42
|
-
};
|
|
43
|
-
var ANSWER_CHANNEL = "clearly-hub-answers";
|
|
44
|
-
|
|
45
46
|
// src/config.ts
|
|
46
47
|
var trimSlash = (url) => url.replace(/\/+$/, "");
|
|
47
48
|
function resolveConfig(options) {
|
|
@@ -53,6 +54,7 @@ function resolveConfig(options) {
|
|
|
53
54
|
const appUrl = trimSlash(options.appUrl);
|
|
54
55
|
const hubUrl = trimSlash(options.hubUrl ?? DEFAULT_HUB_URL);
|
|
55
56
|
const api = trimSlash(options.urls?.api ?? hubUrl);
|
|
57
|
+
const proxy = trimSlash(options.urls?.proxy ?? hubUrl);
|
|
56
58
|
return {
|
|
57
59
|
clientId: options.clientId,
|
|
58
60
|
m2mClientId: options.m2mClientId,
|
|
@@ -64,7 +66,8 @@ function resolveConfig(options) {
|
|
|
64
66
|
urls: {
|
|
65
67
|
screens: trimSlash(options.urls?.screens ?? `${hubUrl}/components`),
|
|
66
68
|
api,
|
|
67
|
-
|
|
69
|
+
proxy,
|
|
70
|
+
usage: options.urls?.usage ?? `${proxy}/register-usage`,
|
|
68
71
|
publicKey: options.urls?.publicKey ?? `${api}/.well-known/public-key.pem`,
|
|
69
72
|
auth: trimSlash(options.urls?.auth ?? DEFAULT_AUTH_URL),
|
|
70
73
|
issuer: options.urls?.issuer ?? DEFAULT_ISSUER
|
|
@@ -84,11 +87,15 @@ function toNodeMiddleware(handle, appUrl) {
|
|
|
84
87
|
try {
|
|
85
88
|
const headers = new Headers();
|
|
86
89
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
87
|
-
if (Array.isArray(value))
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
value.forEach((item) => headers.append(name, item));
|
|
88
92
|
else if (value !== void 0) headers.set(name, value);
|
|
89
93
|
}
|
|
90
94
|
const url = new URL(req.originalUrl ?? req.url ?? "/", appUrl);
|
|
91
|
-
const response = await handle(
|
|
95
|
+
const response = await handle(
|
|
96
|
+
new Request(url, { method: req.method, headers }),
|
|
97
|
+
req
|
|
98
|
+
);
|
|
92
99
|
if (response.headers.get("X-Clearly-Hub") === "unhandled" && next) {
|
|
93
100
|
next();
|
|
94
101
|
return;
|
|
@@ -142,16 +149,24 @@ var Screens = class {
|
|
|
142
149
|
base64Decode(signature),
|
|
143
150
|
new TextEncoder().encode(data)
|
|
144
151
|
);
|
|
145
|
-
if (!isSigned)
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
if (!isSigned)
|
|
153
|
+
throw new AnswerError("This answer was not signed by Clearly.HUB");
|
|
154
|
+
const { request, response } = JSON.parse(
|
|
155
|
+
new TextDecoder().decode(base64Decode(data))
|
|
156
|
+
);
|
|
157
|
+
return { request: request ?? {}, response };
|
|
148
158
|
}
|
|
149
159
|
// Fetched once; a key change needs a restart
|
|
150
160
|
getPublicKey() {
|
|
151
161
|
this.publicKey ??= this.config.fetch(this.config.urls.publicKey).then((response) => {
|
|
152
|
-
if (!response.ok)
|
|
162
|
+
if (!response.ok)
|
|
163
|
+
throw new Error(`No public key at ${this.config.urls.publicKey}`);
|
|
153
164
|
return response.text();
|
|
154
|
-
}).then(
|
|
165
|
+
}).then(
|
|
166
|
+
(pem) => crypto.subtle.importKey("spki", pemToDer(pem), "Ed25519", false, [
|
|
167
|
+
"verify"
|
|
168
|
+
])
|
|
169
|
+
).catch((error) => {
|
|
155
170
|
this.publicKey = void 0;
|
|
156
171
|
throw error;
|
|
157
172
|
});
|
|
@@ -162,7 +177,8 @@ var AnswerError = class extends Error {
|
|
|
162
177
|
};
|
|
163
178
|
function toScreenResult(action, response) {
|
|
164
179
|
const { result, ...answer } = response;
|
|
165
|
-
if (result === "SUCCESS")
|
|
180
|
+
if (result === "SUCCESS")
|
|
181
|
+
return { action, result: "SUCCESS", answer };
|
|
166
182
|
return { action, result: result === "FAILURE" ? "FAILURE" : "CANCELLED" };
|
|
167
183
|
}
|
|
168
184
|
function base64Encode(text) {
|
|
@@ -178,7 +194,11 @@ function readRawQuery(url) {
|
|
|
178
194
|
const params = /* @__PURE__ */ new Map();
|
|
179
195
|
for (const pair of url.search.slice(1).split("&")) {
|
|
180
196
|
const index = pair.indexOf("=");
|
|
181
|
-
if (index > 0)
|
|
197
|
+
if (index > 0)
|
|
198
|
+
params.set(
|
|
199
|
+
pair.slice(0, index),
|
|
200
|
+
decodeURIComponent(pair.slice(index + 1))
|
|
201
|
+
);
|
|
182
202
|
}
|
|
183
203
|
return params;
|
|
184
204
|
}
|
|
@@ -192,7 +212,8 @@ function createHandler(parts) {
|
|
|
192
212
|
const url = new URL(request.url);
|
|
193
213
|
if (!url.pathname.startsWith(config.basePath)) return notFound();
|
|
194
214
|
const route = url.pathname.slice(config.basePath.length) || "/";
|
|
195
|
-
if (request.method !== "GET")
|
|
215
|
+
if (request.method !== "GET")
|
|
216
|
+
return new Response("Method not allowed", { status: 405 });
|
|
196
217
|
const host = request.headers.get("host");
|
|
197
218
|
const appHost = new URL(config.appUrl).host;
|
|
198
219
|
if (host && host !== appHost && (route === ROUTES.signIn || route === ROUTES.open)) {
|
|
@@ -205,8 +226,12 @@ function createHandler(parts) {
|
|
|
205
226
|
response = await routeRequest(route, url, data, parts, { request, req });
|
|
206
227
|
} catch (error) {
|
|
207
228
|
const isRefusedAnswer = error instanceof AnswerError;
|
|
208
|
-
if (!isRefusedAnswer)
|
|
209
|
-
|
|
229
|
+
if (!isRefusedAnswer)
|
|
230
|
+
console.error(`Clearly.HUB: ${route} failed:`, error);
|
|
231
|
+
response = isRefusedAnswer ? errorPage(400, error.message) : errorPage(
|
|
232
|
+
500,
|
|
233
|
+
config.isSecure ? "Something went wrong with Clearly.HUB. Please try again." : describeError(error)
|
|
234
|
+
);
|
|
210
235
|
}
|
|
211
236
|
if (JSON.stringify(data) !== before) {
|
|
212
237
|
for (const cookie of await sessions.write(data, chunkCount)) {
|
|
@@ -223,7 +248,8 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
223
248
|
case ROUTES.callback:
|
|
224
249
|
return finishSignIn(url, session, parts, incoming);
|
|
225
250
|
case ROUTES.signOut:
|
|
226
|
-
for (const key of Object.keys(session))
|
|
251
|
+
for (const key of Object.keys(session))
|
|
252
|
+
delete session[key];
|
|
227
253
|
return redirect(parts.signIn.signOutUrl());
|
|
228
254
|
case ROUTES.session:
|
|
229
255
|
return json(await readSession(session, parts));
|
|
@@ -236,12 +262,16 @@ async function routeRequest(route, url, session, parts, incoming) {
|
|
|
236
262
|
delete session.result;
|
|
237
263
|
return json(result);
|
|
238
264
|
}
|
|
265
|
+
case ROUTES.token:
|
|
266
|
+
return readToken(session, parts);
|
|
239
267
|
default:
|
|
240
268
|
return notFound();
|
|
241
269
|
}
|
|
242
270
|
}
|
|
243
271
|
async function startSignIn(url, session, { config, signIn }) {
|
|
244
|
-
const { url: authorizationUrl, pending } = await signIn.start(
|
|
272
|
+
const { url: authorizationUrl, pending } = await signIn.start(
|
|
273
|
+
safeReturnTo(url, config)
|
|
274
|
+
);
|
|
245
275
|
session.signIn = pending;
|
|
246
276
|
return redirect(authorizationUrl);
|
|
247
277
|
}
|
|
@@ -255,13 +285,28 @@ async function finishSignIn(url, session, { config, signIn }, incoming) {
|
|
|
255
285
|
const { appUserId, redirectTo } = typeof decision === "string" ? { appUserId: decision, redirectTo: void 0 } : decision ?? {};
|
|
256
286
|
session.user = { ...person, appUserId };
|
|
257
287
|
session.tokens = tokens;
|
|
258
|
-
return redirect(
|
|
288
|
+
return redirect(
|
|
289
|
+
`${config.appUrl}${safePath(redirectTo, config) ?? pending.returnTo}`
|
|
290
|
+
);
|
|
259
291
|
}
|
|
260
292
|
async function readSession(session, parts) {
|
|
261
293
|
const accessToken = await getFreshAccessToken(session, parts);
|
|
262
294
|
if (!session.user || !accessToken) return { signedIn: false };
|
|
263
295
|
return { signedIn: true, user: session.user };
|
|
264
296
|
}
|
|
297
|
+
async function readToken(session, parts) {
|
|
298
|
+
const accessToken = await getFreshAccessToken(session, parts);
|
|
299
|
+
if (!session.user || !accessToken)
|
|
300
|
+
return json({ error: "Nobody is signed in with Clearly.HUB" }, 401);
|
|
301
|
+
const token = {
|
|
302
|
+
accessToken,
|
|
303
|
+
expiresIn: Math.max(
|
|
304
|
+
0,
|
|
305
|
+
session.tokens.expiresAt - Math.floor(Date.now() / 1e3)
|
|
306
|
+
)
|
|
307
|
+
};
|
|
308
|
+
return json(token);
|
|
309
|
+
}
|
|
265
310
|
async function getFreshAccessToken(session, { signIn }) {
|
|
266
311
|
const tokens = session.tokens;
|
|
267
312
|
if (!tokens) return void 0;
|
|
@@ -278,7 +323,8 @@ async function getFreshAccessToken(session, { signIn }) {
|
|
|
278
323
|
}
|
|
279
324
|
function openScreen(url, session, { config, screens }) {
|
|
280
325
|
const action = url.searchParams.get("action");
|
|
281
|
-
if (!isScreenAction(action))
|
|
326
|
+
if (!isScreenAction(action))
|
|
327
|
+
return errorPage(400, `Unknown screen: ${action}`);
|
|
282
328
|
const display = url.searchParams.get("display") === "redirect" ? "redirect" : "popup";
|
|
283
329
|
const dataParam = url.searchParams.get("data");
|
|
284
330
|
let data;
|
|
@@ -303,15 +349,21 @@ function openScreen(url, session, { config, screens }) {
|
|
|
303
349
|
}
|
|
304
350
|
async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
305
351
|
const query = readRawQuery(url);
|
|
306
|
-
const {
|
|
352
|
+
const { request, response } = await screens.readAnswer(
|
|
307
353
|
query.get("data") ?? "",
|
|
308
354
|
query.get("signature") ?? ""
|
|
309
355
|
);
|
|
356
|
+
const state = request.state;
|
|
310
357
|
const pending = state ? session.screens?.[state] : void 0;
|
|
311
358
|
if (!pending || Date.now() - pending.createdAt > SCREEN_TTL_MS) {
|
|
312
|
-
throw new AnswerError(
|
|
359
|
+
throw new AnswerError(
|
|
360
|
+
"This answer does not belong to a screen opened in this browser"
|
|
361
|
+
);
|
|
313
362
|
}
|
|
314
363
|
delete session.screens[state];
|
|
364
|
+
if (request.client_id !== config.clientId || request.redirect_url !== screens.answerUrl || request.actions?.length !== 1 || request.actions[0] !== pending.action) {
|
|
365
|
+
throw new AnswerError("This answer was given to another app or screen");
|
|
366
|
+
}
|
|
315
367
|
let result = toScreenResult(pending.action, response);
|
|
316
368
|
try {
|
|
317
369
|
const app = await config.onAnswer?.(result, {
|
|
@@ -321,7 +373,11 @@ async function receiveAnswer(url, session, { config, screens }, incoming) {
|
|
|
321
373
|
});
|
|
322
374
|
if (app !== void 0) result = { ...result, app };
|
|
323
375
|
} catch (error) {
|
|
324
|
-
result = {
|
|
376
|
+
result = {
|
|
377
|
+
action: pending.action,
|
|
378
|
+
result: "FAILURE",
|
|
379
|
+
error: error.message
|
|
380
|
+
};
|
|
325
381
|
}
|
|
326
382
|
if (pending.display === "redirect") {
|
|
327
383
|
session.result = { ref: pending.ref, result };
|
|
@@ -356,11 +412,21 @@ function safePath(path, config) {
|
|
|
356
412
|
}
|
|
357
413
|
}
|
|
358
414
|
var redirect = (location) => new Response(null, { status: 302, headers: { Location: location } });
|
|
359
|
-
var json = (body) => new Response(JSON.stringify(body), {
|
|
360
|
-
|
|
415
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
416
|
+
status,
|
|
417
|
+
headers: {
|
|
418
|
+
"Content-Type": "application/json",
|
|
419
|
+
"Cache-Control": "no-store"
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
var html = (body, status = 200) => new Response(body, {
|
|
423
|
+
status,
|
|
424
|
+
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
425
|
+
});
|
|
426
|
+
var notFound = () => new Response("Not found", {
|
|
427
|
+
status: 404,
|
|
428
|
+
headers: { "X-Clearly-Hub": "unhandled" }
|
|
361
429
|
});
|
|
362
|
-
var html = (body, status = 200) => new Response(body, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
|
|
363
|
-
var notFound = () => new Response("Not found", { status: 404, headers: { "X-Clearly-Hub": "unhandled" } });
|
|
364
430
|
function describeError(error) {
|
|
365
431
|
const messages = [];
|
|
366
432
|
for (let current = error; current instanceof Error && messages.length < 5; current = current.cause) {
|
|
@@ -369,7 +435,10 @@ function describeError(error) {
|
|
|
369
435
|
return messages.join(": ") || String(error);
|
|
370
436
|
}
|
|
371
437
|
function errorPage(status, message) {
|
|
372
|
-
const escaped = message.replace(
|
|
438
|
+
const escaped = message.replace(
|
|
439
|
+
/[&<>"]/g,
|
|
440
|
+
(char) => `&#${char.charCodeAt(0)};`
|
|
441
|
+
);
|
|
373
442
|
return html(
|
|
374
443
|
`<!doctype html><meta charset="utf-8"><title>Clearly.HUB</title><p>${escaped}</p>`,
|
|
375
444
|
status
|
|
@@ -391,7 +460,9 @@ var HubApi = class {
|
|
|
391
460
|
try {
|
|
392
461
|
return await this.config.fetch(url, init);
|
|
393
462
|
} catch (error) {
|
|
394
|
-
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
463
|
+
throw new Error(`Clearly.HUB could not be reached at ${url}`, {
|
|
464
|
+
cause: error
|
|
465
|
+
});
|
|
395
466
|
}
|
|
396
467
|
}
|
|
397
468
|
get hasAppKey() {
|
|
@@ -401,11 +472,16 @@ var HubApi = class {
|
|
|
401
472
|
// until shortly before it expires
|
|
402
473
|
async getAppToken() {
|
|
403
474
|
const now = Date.now() / 1e3;
|
|
404
|
-
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
475
|
+
if (this.appToken && this.appToken.expiresAt > now + 60)
|
|
476
|
+
return this.appToken.value;
|
|
405
477
|
if (!this.hasAppKey) {
|
|
406
|
-
throw new Error(
|
|
478
|
+
throw new Error(
|
|
479
|
+
"Clearly.HUB: m2mClientId and m2mClientSecret are needed for this"
|
|
480
|
+
);
|
|
407
481
|
}
|
|
408
|
-
const credentials = btoa(
|
|
482
|
+
const credentials = btoa(
|
|
483
|
+
`${this.config.m2mClientId}:${this.config.m2mClientSecret}`
|
|
484
|
+
);
|
|
409
485
|
const response = await this.call(`${this.config.urls.auth}/oauth2/token`, {
|
|
410
486
|
method: "POST",
|
|
411
487
|
headers: {
|
|
@@ -421,7 +497,10 @@ var HubApi = class {
|
|
|
421
497
|
throw new Error(`Clearly.HUB refused the app's key (${response.status})`);
|
|
422
498
|
}
|
|
423
499
|
const body = await response.json();
|
|
424
|
-
this.appToken = {
|
|
500
|
+
this.appToken = {
|
|
501
|
+
value: body.access_token,
|
|
502
|
+
expiresAt: now + body.expires_in
|
|
503
|
+
};
|
|
425
504
|
return body.access_token;
|
|
426
505
|
}
|
|
427
506
|
// Every hub that bought your app, with status and your plan id
|
|
@@ -429,11 +508,16 @@ var HubApi = class {
|
|
|
429
508
|
if (this.subscriptions && Date.now() - this.subscriptions.fetchedAt < SUBSCRIPTIONS_CACHE_MS) {
|
|
430
509
|
return this.subscriptions.value;
|
|
431
510
|
}
|
|
432
|
-
const response = await this.call(
|
|
433
|
-
|
|
434
|
-
|
|
511
|
+
const response = await this.call(
|
|
512
|
+
`${this.config.urls.api}/api/app-api/subscriptions`,
|
|
513
|
+
{
|
|
514
|
+
headers: { Authorization: `Bearer ${await this.getAppToken()}` }
|
|
515
|
+
}
|
|
516
|
+
);
|
|
435
517
|
if (!response.ok) {
|
|
436
|
-
throw new Error(
|
|
518
|
+
throw new Error(
|
|
519
|
+
`Could not read the subscriptions from Clearly.HUB (${response.status})`
|
|
520
|
+
);
|
|
437
521
|
}
|
|
438
522
|
const items = await response.json();
|
|
439
523
|
const value = items.map((item) => ({
|
|
@@ -466,12 +550,16 @@ var HubApi = class {
|
|
|
466
550
|
});
|
|
467
551
|
if (!response.ok) {
|
|
468
552
|
const body = await response.json().catch(() => ({}));
|
|
469
|
-
throw new Error(
|
|
553
|
+
throw new Error(
|
|
554
|
+
`Clearly.HUB did not accept the usage: ${body.message ?? response.status}`
|
|
555
|
+
);
|
|
470
556
|
}
|
|
471
557
|
}
|
|
472
558
|
};
|
|
473
559
|
function pickSubscription(subscriptions, hubId) {
|
|
474
|
-
const ofHub = subscriptions.filter(
|
|
560
|
+
const ofHub = subscriptions.filter(
|
|
561
|
+
(subscription) => subscription.hubId === hubId
|
|
562
|
+
);
|
|
475
563
|
return ofHub.find((s) => s.status === "ACTIVE") ?? ofHub.find((s) => s.status === "PENDING");
|
|
476
564
|
}
|
|
477
565
|
|
|
@@ -488,7 +576,12 @@ var SignIn = class {
|
|
|
488
576
|
}
|
|
489
577
|
// Discovery happens once, on the first sign-in
|
|
490
578
|
getConfiguration() {
|
|
491
|
-
this.configuration ??= oidc.discovery(
|
|
579
|
+
this.configuration ??= oidc.discovery(
|
|
580
|
+
new URL(this.config.urls.issuer),
|
|
581
|
+
this.config.clientId,
|
|
582
|
+
void 0,
|
|
583
|
+
oidc.None()
|
|
584
|
+
).catch((error) => {
|
|
492
585
|
this.configuration = void 0;
|
|
493
586
|
throw error;
|
|
494
587
|
});
|
|
@@ -510,11 +603,15 @@ var SignIn = class {
|
|
|
510
603
|
return { url: url.href, pending: { codeVerifier, state, nonce, returnTo } };
|
|
511
604
|
}
|
|
512
605
|
async finish(currentUrl, pending) {
|
|
513
|
-
const tokens = await oidc.authorizationCodeGrant(
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
606
|
+
const tokens = await oidc.authorizationCodeGrant(
|
|
607
|
+
await this.getConfiguration(),
|
|
608
|
+
currentUrl,
|
|
609
|
+
{
|
|
610
|
+
pkceCodeVerifier: pending.codeVerifier,
|
|
611
|
+
expectedState: pending.state,
|
|
612
|
+
expectedNonce: pending.nonce
|
|
613
|
+
}
|
|
614
|
+
);
|
|
518
615
|
const claims = tokens.claims();
|
|
519
616
|
if (!claims) throw new Error("Clearly.HUB sent no ID token");
|
|
520
617
|
return {
|
|
@@ -527,7 +624,10 @@ var SignIn = class {
|
|
|
527
624
|
};
|
|
528
625
|
}
|
|
529
626
|
async refresh(refreshToken) {
|
|
530
|
-
const tokens = await oidc.refreshTokenGrant(
|
|
627
|
+
const tokens = await oidc.refreshTokenGrant(
|
|
628
|
+
await this.getConfiguration(),
|
|
629
|
+
refreshToken
|
|
630
|
+
);
|
|
531
631
|
return toSessionTokens(tokens, refreshToken);
|
|
532
632
|
}
|
|
533
633
|
// Signing out of Clearly.HUB too, or the next sign-in goes straight through
|
|
@@ -560,12 +660,16 @@ function parseCookies(header) {
|
|
|
560
660
|
const cookies = /* @__PURE__ */ new Map();
|
|
561
661
|
for (const part of header.split(";")) {
|
|
562
662
|
const index = part.indexOf("=");
|
|
563
|
-
if (index > 0)
|
|
663
|
+
if (index > 0)
|
|
664
|
+
cookies.set(part.slice(0, index).trim(), part.slice(index + 1).trim());
|
|
564
665
|
}
|
|
565
666
|
return cookies;
|
|
566
667
|
}
|
|
567
668
|
async function deriveKey(secret) {
|
|
568
|
-
const digest = await crypto.subtle.digest(
|
|
669
|
+
const digest = await crypto.subtle.digest(
|
|
670
|
+
"SHA-256",
|
|
671
|
+
new TextEncoder().encode(secret)
|
|
672
|
+
);
|
|
569
673
|
return new Uint8Array(digest);
|
|
570
674
|
}
|
|
571
675
|
var SessionStore = class {
|
|
@@ -583,8 +687,14 @@ var SessionStore = class {
|
|
|
583
687
|
}
|
|
584
688
|
if (!chunks.length) return { data: {}, chunkCount: 0 };
|
|
585
689
|
try {
|
|
586
|
-
const { plaintext } = await compactDecrypt(
|
|
587
|
-
|
|
690
|
+
const { plaintext } = await compactDecrypt(
|
|
691
|
+
chunks.join(""),
|
|
692
|
+
await this.keyPromise
|
|
693
|
+
);
|
|
694
|
+
return {
|
|
695
|
+
data: JSON.parse(new TextDecoder().decode(plaintext)),
|
|
696
|
+
chunkCount: chunks.length
|
|
697
|
+
};
|
|
588
698
|
} catch {
|
|
589
699
|
return { data: {}, chunkCount: chunks.length };
|
|
590
700
|
}
|
|
@@ -592,7 +702,9 @@ var SessionStore = class {
|
|
|
592
702
|
// Set-Cookie headers for the new session, clearing chunks it no longer needs
|
|
593
703
|
async write(data, previousChunkCount) {
|
|
594
704
|
const isEmpty = Object.keys(data).length === 0;
|
|
595
|
-
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
705
|
+
const chunks = isEmpty ? [] : (await this.encrypt(data)).match(
|
|
706
|
+
new RegExp(`.{1,${CHUNK_SIZE}}`, "g")
|
|
707
|
+
);
|
|
596
708
|
const attributes = `Path=/; HttpOnly; SameSite=Lax${this.config.isSecure ? "; Secure" : ""}`;
|
|
597
709
|
const headers = chunks.map(
|
|
598
710
|
(chunk, i) => `${COOKIE_NAME}.${i}=${chunk}; Max-Age=${MAX_AGE_SECONDS}; ${attributes}`
|
|
@@ -639,10 +751,17 @@ function clearlyHub(options) {
|
|
|
639
751
|
},
|
|
640
752
|
getUserToken,
|
|
641
753
|
// Fetch protected or paid data (for example from DATA_PICKER) with the
|
|
642
|
-
// person's key. Open data works without it.
|
|
754
|
+
// person's key. Open data works without it. Only URLs on Clearly.HUB's
|
|
755
|
+
// data proxy: the key would let anyone else act as this person
|
|
643
756
|
async fetchAsUser(request, url, init = {}) {
|
|
757
|
+
if (!isOnProxy(url, config.urls.proxy)) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Clearly.HUB: fetchAsUser only sends the person's key to Clearly.HUB's data proxy (${config.urls.proxy}, set with urls.proxy), not to ${url}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
644
762
|
const token = await getUserToken(request);
|
|
645
|
-
if (!token)
|
|
763
|
+
if (!token)
|
|
764
|
+
throw new Error("Clearly.HUB: nobody is signed in with Clearly.HUB");
|
|
646
765
|
const headers = new Headers(init.headers);
|
|
647
766
|
headers.set("Authorization", token);
|
|
648
767
|
return config.fetch(url, { ...init, headers });
|
|
@@ -667,11 +786,18 @@ function clearlyHub(options) {
|
|
|
667
786
|
}) {
|
|
668
787
|
const person = sub ?? (request && (await parts.sessions.read(request)).data.user?.sub);
|
|
669
788
|
if (!person) {
|
|
670
|
-
throw new Error(
|
|
789
|
+
throw new Error(
|
|
790
|
+
"Clearly.HUB: reportUsage needs the sub of a Clearly.HUB account"
|
|
791
|
+
);
|
|
671
792
|
}
|
|
672
|
-
const subscription = pickSubscription(
|
|
793
|
+
const subscription = pickSubscription(
|
|
794
|
+
await parts.api.getSubscriptions(),
|
|
795
|
+
hubId
|
|
796
|
+
);
|
|
673
797
|
if (subscription?.status !== "ACTIVE") {
|
|
674
|
-
throw new Error(
|
|
798
|
+
throw new Error(
|
|
799
|
+
`Clearly.HUB: hub ${hubId} has no active subscription to your app`
|
|
800
|
+
);
|
|
675
801
|
}
|
|
676
802
|
await parts.api.reportUsage({
|
|
677
803
|
sub: person,
|
|
@@ -682,6 +808,15 @@ function clearlyHub(options) {
|
|
|
682
808
|
}
|
|
683
809
|
};
|
|
684
810
|
}
|
|
811
|
+
function isOnProxy(url, proxy) {
|
|
812
|
+
try {
|
|
813
|
+
const target = new URL(url);
|
|
814
|
+
const base = new URL(`${proxy}/`);
|
|
815
|
+
return target.origin === base.origin && target.pathname.startsWith(base.pathname);
|
|
816
|
+
} catch {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
685
820
|
export {
|
|
686
821
|
clearlyHub
|
|
687
822
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clearly-hub/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Clearly.HUB for Node servers: sign-in, hosted screens, subscriptions and usage in one handler",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Future Insight Group BV",
|