webdaemon 0.0.0-241205172853
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/index.js +8 -0
- package/package.json +13 -0
- package/package.json.template +13 -0
- package/src/js/Alert.js +33 -0
- package/src/js/BrowserApp.js +344 -0
- package/src/js/Digest.js +45 -0
- package/src/js/KeyPair.js +38 -0
- package/src/js/ParentHelper.js +302 -0
- package/src/js/QrCode.js +37 -0
- package/src/js/README.html +13 -0
- package/src/js/README.md +4 -0
- package/src/js/README.pdf +0 -0
- package/src/js/Storage.js +182 -0
- package/src/js/Token.js +529 -0
- package/src/ts/Assertions.ts +41 -0
- package/src/ts/Lifecycle.ts +315 -0
- package/src/ts/README.html +13 -0
- package/src/ts/README.md +4 -0
- package/src/ts/README.pdf +0 -0
- package/src/ts/Requests.ts +131 -0
- package/src/ts/Responses.ts +132 -0
package/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './src/js/Alert.js'
|
|
2
|
+
export * from './src/js/BrowserApp.js'
|
|
3
|
+
export * from './src/js/Digest.js'
|
|
4
|
+
export * from './src/js/KeyPair.js'
|
|
5
|
+
export * from './src/js/ParentHelper.js'
|
|
6
|
+
export * from './src/js/QrCode.js'
|
|
7
|
+
export * from './src/js/Storage.js'
|
|
8
|
+
export * from './src/js/Token.js'
|
package/package.json
ADDED
package/src/js/Alert.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { BrowserApp } from './BrowserApp.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns the currently raised launch alert for this app,
|
|
5
|
+
* or null if none.
|
|
6
|
+
*
|
|
7
|
+
* @return { Promise<AlertType | null> } raised launch alert, or null if none.
|
|
8
|
+
* @throws {string} exception if alert catalog cannot be retrieved.
|
|
9
|
+
*/
|
|
10
|
+
export async function getLaunchAlert() {
|
|
11
|
+
const app = await BrowserApp.getInstance()
|
|
12
|
+
const origin = app.getPartyOrigin()
|
|
13
|
+
const url = new URL(`${origin}/catalog`)
|
|
14
|
+
url.searchParams.append('alert', '')
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
headers: {
|
|
17
|
+
'X-Tabserver-Token': this.getTokenBase64(),
|
|
18
|
+
'Accept': 'application/json'
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
const json = await response.json()
|
|
22
|
+
if ('error' in json) {
|
|
23
|
+
throw json.error
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const alert = json.ok.alert.find(
|
|
27
|
+
alert => (
|
|
28
|
+
alert.type == 'LAUNCH' &&
|
|
29
|
+
alert.sourceUrl == app.getAppUrl()
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
return alert || null
|
|
33
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { Token } from './Token.js'
|
|
2
|
+
import { Storage } from './Storage.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module
|
|
6
|
+
* Provides convenience support for WebDaemon apps delivered
|
|
7
|
+
* through browsers.
|
|
8
|
+
*
|
|
9
|
+
* Use the singleton pattern, providing app name on first call
|
|
10
|
+
* to getInstance.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const PARTY_PARAM = 'party'
|
|
14
|
+
|
|
15
|
+
export class BrowserApp {
|
|
16
|
+
static #instance
|
|
17
|
+
|
|
18
|
+
#appName
|
|
19
|
+
#appUrlKey
|
|
20
|
+
#partyKey
|
|
21
|
+
#tokensKey
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Gets the singleton instance of BrowserApp. On invocations
|
|
25
|
+
* after the first, the app name must be omitted or match
|
|
26
|
+
* the first-used app name.
|
|
27
|
+
*
|
|
28
|
+
* @param {string=} appName
|
|
29
|
+
* @return {Promise<BrowserApp>} instance of browser app.
|
|
30
|
+
*/
|
|
31
|
+
static async getInstance(appName = null) {
|
|
32
|
+
if (
|
|
33
|
+
!appName &&
|
|
34
|
+
!BrowserApp.#instance
|
|
35
|
+
) {
|
|
36
|
+
throw `Must provide app name`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
appName &&
|
|
41
|
+
BrowserApp.#instance &&
|
|
42
|
+
appName !== BrowserApp.#instance.#appName
|
|
43
|
+
) {
|
|
44
|
+
throw `Cannot change app name to ${appName}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!BrowserApp.#instance) {
|
|
48
|
+
BrowserApp.#instance = new BrowserApp(appName)
|
|
49
|
+
await BrowserApp.#instance.init()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return BrowserApp.#instance
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Constructs a BrowserApp instance with the app name. This should be
|
|
57
|
+
* unique per origin. Spaces are _NOT_ allowed in this name.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} appName a unique name for this app per origin.
|
|
60
|
+
*/
|
|
61
|
+
constructor(appName) {
|
|
62
|
+
this.#appName = appName
|
|
63
|
+
this.#partyKey = `${appName}Party`
|
|
64
|
+
this.#tokensKey = `${appName}Tokens`
|
|
65
|
+
this.#appUrlKey = `${appName}Url`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Initialises the instance with the party name and tokens passed
|
|
70
|
+
* in the #fragment and ?query string search parameters.
|
|
71
|
+
*
|
|
72
|
+
* - party is passed in the special parameter 'party'.
|
|
73
|
+
* - tokens are passed in the remaining parameters keyed by host name.
|
|
74
|
+
*
|
|
75
|
+
* The entire #fragment is parsed into search params, if present.
|
|
76
|
+
* Query params whose name starts with the special character 'æ' are
|
|
77
|
+
* extracted, if present.
|
|
78
|
+
*
|
|
79
|
+
* The window location is replaced with the 'clean' version that
|
|
80
|
+
* no longer includes tokens.
|
|
81
|
+
*
|
|
82
|
+
* @return {Promise<BrowserApp>} instance promise.
|
|
83
|
+
* @throws {string} error if party token `src` does not match our window location.
|
|
84
|
+
*/
|
|
85
|
+
async init() {
|
|
86
|
+
const ourUrl = new URL(globalThis.location)
|
|
87
|
+
|
|
88
|
+
// Extract and clear the fragment component before storing the url.
|
|
89
|
+
const tokenParams = new URLSearchParams(ourUrl.hash.substring(1))
|
|
90
|
+
ourUrl.hash = ''
|
|
91
|
+
|
|
92
|
+
// Extract matching query parameters, add them to the token params
|
|
93
|
+
// and clear them from the url.
|
|
94
|
+
for (const [key, value] of ourUrl.searchParams) {
|
|
95
|
+
if (key.startsWith('æ')) {
|
|
96
|
+
tokenParams.set(key.substring(1), value)
|
|
97
|
+
ourUrl.searchParams.delete(key)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
sessionStorage[this.#appUrlKey] = ourUrl.toString()
|
|
102
|
+
|
|
103
|
+
if (tokenParams.has(PARTY_PARAM)) {
|
|
104
|
+
sessionStorage[this.#tokensKey] = tokenParams.toString()
|
|
105
|
+
const identityToken = this.getToken()
|
|
106
|
+
await identityToken.verifySignatory()
|
|
107
|
+
sessionStorage[this.#partyKey] = identityToken.getParty()
|
|
108
|
+
|
|
109
|
+
const srcUrl = identityToken.getSourceUrl()
|
|
110
|
+
if (srcUrl.origin !== ourUrl.origin || srcUrl.pathname !== ourUrl.pathname) {
|
|
111
|
+
throw `Party token is for different app: ${srcUrl}`
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
globalThis.history.replaceState(null, '', ourUrl.toString())
|
|
116
|
+
return this
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns true if this app is an orphan, i.e. has not been launched from
|
|
121
|
+
* a Dæmon shell.
|
|
122
|
+
*
|
|
123
|
+
* Otherwise returns false.
|
|
124
|
+
*
|
|
125
|
+
* @return {boolean} true if an orphan without Dæmon, otherwise false.
|
|
126
|
+
*/
|
|
127
|
+
isOrphan() {
|
|
128
|
+
return (
|
|
129
|
+
sessionStorage[this.#partyKey] == undefined ||
|
|
130
|
+
sessionStorage[this.#tokensKey] == undefined
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns the app name. This is normally used to disambiguate properties
|
|
136
|
+
* of this app from others, such as those sharing a web origin and therefore
|
|
137
|
+
* sharing localStorage or sessionStorage.
|
|
138
|
+
*
|
|
139
|
+
* Note that this is a client-side name not known to the tabserver.
|
|
140
|
+
*
|
|
141
|
+
* @return {string} the app name.
|
|
142
|
+
*/
|
|
143
|
+
getAppName() {
|
|
144
|
+
return this.#appName
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Returns the app url, obtained from window.location at time of app init,
|
|
149
|
+
* or the empty string if not initialised.
|
|
150
|
+
*
|
|
151
|
+
* @return {string} the app url, or the empty string.
|
|
152
|
+
*/
|
|
153
|
+
getAppUrl() {
|
|
154
|
+
return sessionStorage[this.#appUrlKey] || ''
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Returns the party hostname, being the Dæmon that launched this app.
|
|
159
|
+
*
|
|
160
|
+
* @return {string} the party (Dæmon) hostname.
|
|
161
|
+
*/
|
|
162
|
+
getParty() {
|
|
163
|
+
return sessionStorage[this.#partyKey]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Returns the party origin, being the protocol (http: or https:)
|
|
168
|
+
* and the party hostname.
|
|
169
|
+
*
|
|
170
|
+
* @return {string} the party origin.
|
|
171
|
+
*/
|
|
172
|
+
getPartyOrigin() {
|
|
173
|
+
return `${globalThis.location.protocol}//${this.getParty()}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Returns the base64-encoded token for the supplied party. If no
|
|
178
|
+
* party is specified, returns the token for the launching party.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} party the party, or the launching party if not specified.
|
|
181
|
+
* @return {string} the base64-encoded token for the party.
|
|
182
|
+
*/
|
|
183
|
+
getTokenBase64(party = PARTY_PARAM) {
|
|
184
|
+
const tokens = sessionStorage[this.#tokensKey]
|
|
185
|
+
const searchParams = new URLSearchParams(tokens)
|
|
186
|
+
const tokenBase64 = searchParams.get(party)
|
|
187
|
+
if (!tokenBase64) {
|
|
188
|
+
throw `No token for ${party}, check 'audience' in YML`
|
|
189
|
+
}
|
|
190
|
+
return tokenBase64
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Gets the token for a party either as a base64-encoded string, or as a
|
|
196
|
+
* Token object depending on the asBase64 argument.
|
|
197
|
+
*
|
|
198
|
+
* If the party is not passed or is null, the default is the launching party.
|
|
199
|
+
* If the asBase64 is not passed, the default is true.
|
|
200
|
+
*
|
|
201
|
+
* @param {string=} party the audience for the token. Defaults to the identity token 'party'.
|
|
202
|
+
* @return {Token} the pre-generated token for the given party.
|
|
203
|
+
*/
|
|
204
|
+
getToken(party = PARTY_PARAM) {
|
|
205
|
+
const tokenBase64 = this.getTokenBase64(party)
|
|
206
|
+
return new Token(tokenBase64)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Gets the value of the named parameter, or null if no value is supplied.
|
|
211
|
+
*
|
|
212
|
+
* The parameter name is case insensitive.
|
|
213
|
+
*
|
|
214
|
+
* The parameter is specified as:
|
|
215
|
+
* - an attribute on the webdaemon meta tag, or
|
|
216
|
+
* - a search parameter in the query string of the URL
|
|
217
|
+
*
|
|
218
|
+
* where the query string parameter takes priority if present.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} name
|
|
221
|
+
* @return {string | null} parameter value or null if not present.
|
|
222
|
+
*/
|
|
223
|
+
getParam(name) {
|
|
224
|
+
|
|
225
|
+
// Return a matching search parameter if present.
|
|
226
|
+
const searchParams = new URL(globalThis.location).searchParams
|
|
227
|
+
for (const [paramName, value] of searchParams) {
|
|
228
|
+
if (name.toLowerCase() == paramName.toLowerCase()) {
|
|
229
|
+
return value
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Return meta element attribute if present, or null.
|
|
234
|
+
const metaElement = document.querySelector('link[rel=webdaemon]')
|
|
235
|
+
return metaElement.getAttribute(name)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Returns true if the named parameter is present, even if with empty
|
|
240
|
+
* or null value.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} name
|
|
243
|
+
* @return {boolean} true if the parameter exists, otherwise false.
|
|
244
|
+
*/
|
|
245
|
+
hasParam(name) {
|
|
246
|
+
const searchParams = new URL(globalThis.location).searchParams
|
|
247
|
+
if (searchParams.has(name)) {
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const metaElement = document.querySelector('link[rel=webdaemon]')
|
|
252
|
+
return metaElement.hasAttribute(name)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Returns the currently raised launch alert for this app,
|
|
257
|
+
* or null if none.
|
|
258
|
+
*
|
|
259
|
+
* @return { Promise<AlertType | null> } raised launch alert, or null if none.
|
|
260
|
+
* @throws {string} exception if alert catalog cannot be retrieved.
|
|
261
|
+
*/
|
|
262
|
+
async getLaunchAlert() {
|
|
263
|
+
const origin = this.getPartyOrigin()
|
|
264
|
+
const url = new URL(`${origin}/catalog`)
|
|
265
|
+
url.searchParams.append('alert', '')
|
|
266
|
+
const response = await fetch(url, {
|
|
267
|
+
headers: {
|
|
268
|
+
'X-Tabserver-Token': this.getTokenBase64(),
|
|
269
|
+
'Accept': 'application/json'
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
const json = await response.json()
|
|
273
|
+
if ('error' in json) {
|
|
274
|
+
throw json.error
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const alert = json.ok.alert.find(
|
|
278
|
+
alert => (
|
|
279
|
+
alert.type == 'LAUNCH' &&
|
|
280
|
+
alert.sourceUrl == this.getAppUrl()
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
return alert || null
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolves the currently raised launch alert for this app, if any.
|
|
288
|
+
*
|
|
289
|
+
* @throws {string} exception if alert cannot be resolved.
|
|
290
|
+
*/
|
|
291
|
+
async resolveLaunchAlert() {
|
|
292
|
+
const origin = this.getPartyOrigin()
|
|
293
|
+
const url = new URL(`${origin}/resolve`)
|
|
294
|
+
const response = await fetch(url, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: {
|
|
297
|
+
'X-Tabserver-Token': this.getTokenBase64(),
|
|
298
|
+
'Content-Type': 'application/json'
|
|
299
|
+
},
|
|
300
|
+
body: JSON.stringify({
|
|
301
|
+
type: 'LAUNCH',
|
|
302
|
+
source: this.getAppUrl()
|
|
303
|
+
})
|
|
304
|
+
})
|
|
305
|
+
const json = await response.json()
|
|
306
|
+
if ('error' in json) {
|
|
307
|
+
throw json.error
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolves the currently raised launch alert for this app, if any.
|
|
313
|
+
*
|
|
314
|
+
* @throws {string} exception if alert cannot be resolved.
|
|
315
|
+
*/
|
|
316
|
+
async rejectLaunchAlert() {
|
|
317
|
+
const origin = this.getPartyOrigin()
|
|
318
|
+
const url = new URL(`${origin}/reject`)
|
|
319
|
+
const response = await fetch(url, {
|
|
320
|
+
method: 'POST',
|
|
321
|
+
headers: {
|
|
322
|
+
'X-Tabserver-Token': this.getTokenBase64(),
|
|
323
|
+
'Content-Type': 'application/json'
|
|
324
|
+
},
|
|
325
|
+
body: JSON.stringify({
|
|
326
|
+
type: 'LAUNCH',
|
|
327
|
+
source: this.getAppUrl()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
const json = await response.json()
|
|
331
|
+
if ('error' in json) {
|
|
332
|
+
throw json.error
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Gets a Storage instance using the party token.
|
|
338
|
+
*
|
|
339
|
+
* @returns {Storage} instance.
|
|
340
|
+
*/
|
|
341
|
+
getStorage() {
|
|
342
|
+
return new Storage(this.getToken())
|
|
343
|
+
}
|
|
344
|
+
}
|
package/src/js/Digest.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a SHA-1 digest of the supplied string, and returns
|
|
3
|
+
* it as a base64 string optionally truncated to the first 'length'
|
|
4
|
+
* characters.
|
|
5
|
+
*
|
|
6
|
+
* @param {string} message to digest.
|
|
7
|
+
* @return {Promise<string>} base64-encoded digest.
|
|
8
|
+
*/
|
|
9
|
+
export async function shortSafeDigest(message, length = 0) {
|
|
10
|
+
const msgUint8 = new TextEncoder().encode(message)
|
|
11
|
+
if (!crypto.subtle) {
|
|
12
|
+
throw 'Requires secure origin (localhost or https:)'
|
|
13
|
+
}
|
|
14
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
|
|
15
|
+
const hashArray = new Uint8Array(hashBuffer)
|
|
16
|
+
const byteString = String.fromCodePoint(...hashArray)
|
|
17
|
+
const base64 = btoa(byteString)
|
|
18
|
+
const base64Safe = base64
|
|
19
|
+
.replaceAll('+','-')
|
|
20
|
+
.replaceAll('/','_')
|
|
21
|
+
.replaceAll('=','')
|
|
22
|
+
return length ? base64Safe.substring(0, length) : base64Safe
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generates a SHA-1 digest of the supplied string, and returns
|
|
27
|
+
* it as a hex string optionally truncated to the first 'length'
|
|
28
|
+
* characters.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} message to digest.
|
|
31
|
+
* @return {Promise<string>} hex-encoded digest.
|
|
32
|
+
*/
|
|
33
|
+
export async function shortHexDigest(message, length = 0) {
|
|
34
|
+
const msgUint8 = new TextEncoder().encode(message)
|
|
35
|
+
if (!crypto.subtle) {
|
|
36
|
+
throw 'Requires secure origin (localhost or https:)'
|
|
37
|
+
}
|
|
38
|
+
const hashBuffer = await crypto.subtle.digest("SHA-1", msgUint8)
|
|
39
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
40
|
+
const hashHex = hashArray
|
|
41
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
42
|
+
.join('')
|
|
43
|
+
|
|
44
|
+
return length ? hashHex.substring(0, length) : hashHex
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const DEFAULT_ALGORITHM = {
|
|
2
|
+
name: 'RSASSA-PKCS1-v1_5',
|
|
3
|
+
modulusLength: 2048,
|
|
4
|
+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
5
|
+
hash: 'SHA-256'
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class KeyPair {
|
|
9
|
+
#algorithm
|
|
10
|
+
#keypair
|
|
11
|
+
|
|
12
|
+
constructor(algorithm = DEFAULT_ALGORITHM) {
|
|
13
|
+
this.#algorithm = algorithm
|
|
14
|
+
if (!crypto.subtle) {
|
|
15
|
+
throw 'Crypto.subtle requires secure environment'
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async generate() {
|
|
20
|
+
this.#keypair = await crypto.subtle.generateKey(
|
|
21
|
+
this.#algorithm,
|
|
22
|
+
true,
|
|
23
|
+
['sign', 'verify']
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async publicJwk() {
|
|
28
|
+
const publicKey = this.#keypair.publicKey
|
|
29
|
+
const publicJwk = await crypto.subtle.exportKey('jwk', publicKey)
|
|
30
|
+
return publicJwk
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async privateJwk() {
|
|
34
|
+
const privateKey = this.#keypair.privateKey
|
|
35
|
+
const privateJwk = await crypto.subtle.exportKey('jwk', privateKey)
|
|
36
|
+
return privateJwk
|
|
37
|
+
}
|
|
38
|
+
}
|