@wtfalch/desktop 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +257 -2
- package/package.json +38 -4
- package/src/access.mjs +90 -0
- package/src/check-origin.mjs +42 -0
- package/src/company.mjs +51 -0
- package/src/config.mjs +96 -0
- package/src/deep-link.mjs +69 -0
- package/src/index.mjs +15 -0
- package/src/lifecycle.mjs +20 -0
- package/src/login.mjs +215 -0
- package/src/menu.mjs +28 -0
- package/src/resolve.mjs +105 -0
- package/src/security.mjs +96 -0
- package/src/shell.mjs +471 -0
- package/src/strategies/cookie-handoff.mjs +79 -0
- package/src/updater.mjs +60 -0
- package/src/welcome-dir.mjs +5 -0
- package/src/window-state.mjs +39 -0
- package/welcome/fonts/OFL-bricolage-grotesque.txt +93 -0
- package/welcome/fonts/OFL-figtree.txt +93 -0
- package/welcome/fonts/bricolage-grotesque-latin-wght-normal.woff2 +0 -0
- package/welcome/fonts/figtree-latin-wght-normal.woff2 +0 -0
- package/welcome/sticker.svg +1 -0
- package/welcome/welcome-preload.cjs +10 -0
- package/welcome/welcome.css +354 -0
- package/welcome/welcome.html +56 -0
- package/welcome/welcome.js +176 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Main process only. Two kinds of link, each opt-in.
|
|
2
|
+
//
|
|
3
|
+
// parseLoginLink: the end of a browser sign-in, <scheme>://auth/done?id=<uuid v4>
|
|
4
|
+
// (the cookie-handoff strategy). It returns the id or undefined. The link
|
|
5
|
+
// carries no credential.
|
|
6
|
+
//
|
|
7
|
+
// resolveDeepLink and friends: a link that opens a page, <scheme>://<path> or
|
|
8
|
+
// an https link to the app's own origin, for apps that configure
|
|
9
|
+
// `navigationLinks` (from Agora). The target is rebuilt from the trusted
|
|
10
|
+
// origin and a validated path, and must pass the same navigation rule as the
|
|
11
|
+
// window, so a link reaches no more than the page could navigate to itself.
|
|
12
|
+
import { allowedNavigation } from './security.mjs';
|
|
13
|
+
|
|
14
|
+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
export function parseLoginLink(value, scheme) {
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(value);
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
if (
|
|
24
|
+
url.protocol !== `${scheme}:` ||
|
|
25
|
+
url.host !== 'auth' ||
|
|
26
|
+
url.pathname !== '/done' ||
|
|
27
|
+
url.hash ||
|
|
28
|
+
url.username ||
|
|
29
|
+
url.password ||
|
|
30
|
+
url.port
|
|
31
|
+
)
|
|
32
|
+
return undefined;
|
|
33
|
+
const params = [...url.searchParams];
|
|
34
|
+
if (params.length !== 1 || params[0][0] !== 'id' || !UUID_V4.test(params[0][1])) return undefined;
|
|
35
|
+
return params[0][1].toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// <scheme>://h/<id>/... : the authority is the first path segment, so host and
|
|
39
|
+
// pathname together reassemble the path.
|
|
40
|
+
export function resolveDeepLink(value, origin, { scheme, pathPrefix }) {
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(value);
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
let path;
|
|
48
|
+
if (url.protocol === `${scheme}:`) path = `/${url.host}${url.pathname}`;
|
|
49
|
+
else if (url.protocol === 'https:' && url.origin === origin) path = url.pathname;
|
|
50
|
+
else return undefined;
|
|
51
|
+
if (path !== pathPrefix && !path.startsWith(`${pathPrefix}/`)) return undefined;
|
|
52
|
+
const target = `${origin}${path}${url.search}${url.hash}`;
|
|
53
|
+
return allowedNavigation(target, origin) ? target : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function findDeepLinkArg(argv, origin, options) {
|
|
57
|
+
for (const arg of argv) {
|
|
58
|
+
const target = resolveDeepLink(arg, origin, options);
|
|
59
|
+
if (target) return target;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Cold start: a link macOS handed us via 'open-url' before the window existed
|
|
65
|
+
// (queued) wins over one passed as a launch argument (argv): the queued one is
|
|
66
|
+
// the more specific, more recent signal (Agora PR 308).
|
|
67
|
+
export function initialDeepLink(queued, argv, origin, options) {
|
|
68
|
+
return findDeepLinkArg(queued, origin, options) ?? findDeepLinkArg(argv, origin, options);
|
|
69
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @wtfalch/desktop: the shared Electron shell. See README.md.
|
|
2
|
+
export { welcomeDir } from './welcome-dir.mjs';
|
|
3
|
+
export { validateConfig } from './config.mjs';
|
|
4
|
+
export { cookieHandoff } from './strategies/cookie-handoff.mjs';
|
|
5
|
+
export { runCheck } from './check-origin.mjs';
|
|
6
|
+
export { isAccessHost, passedAccess } from './access.mjs';
|
|
7
|
+
|
|
8
|
+
// Starts the shell in Electron's main process. `electron` is imported here,
|
|
9
|
+
// not at the top, so that importing this package elsewhere stays possible.
|
|
10
|
+
export async function startShell(config) {
|
|
11
|
+
const { validateConfig } = await import('./config.mjs');
|
|
12
|
+
validateConfig(config);
|
|
13
|
+
const { run } = await import('./shell.mjs');
|
|
14
|
+
run(config);
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Main process only. Mac keeps the app resident: closing the window hides it
|
|
2
|
+
// instead of destroying it, the dock icon ('activate') shows it again, and
|
|
3
|
+
// only a real quit (Cmd+Q, app.quit()) lets the window actually close. Other
|
|
4
|
+
// platforms keep quitting on close; there is no tray to fall back to there.
|
|
5
|
+
export function wireCloseBehavior(app, window, platform = process.platform) {
|
|
6
|
+
let quitting = false;
|
|
7
|
+
app.on('before-quit', () => {
|
|
8
|
+
quitting = true;
|
|
9
|
+
});
|
|
10
|
+
window.on('close', (event) => {
|
|
11
|
+
if (platform === 'darwin' && !quitting) {
|
|
12
|
+
event.preventDefault();
|
|
13
|
+
window.hide();
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
app.on('activate', () => window.show());
|
|
17
|
+
app.on('window-all-closed', () => {
|
|
18
|
+
if (platform !== 'darwin') app.quit();
|
|
19
|
+
});
|
|
20
|
+
}
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Main process only. The browser handoff, from the shell's side.
|
|
2
|
+
//
|
|
3
|
+
// The shell makes a random verifier and opens the system browser with its
|
|
4
|
+
// sha256 as the challenge. The person signs in there. The browser ends on
|
|
5
|
+
// <scheme>://auth/done?id=<id>; the shell then POSTs the id and the verifier and
|
|
6
|
+
// receives the session cookies once, and installs them in the company's
|
|
7
|
+
// partition. The link alone is worth nothing: only the verifier redeems it.
|
|
8
|
+
// Any app can open a <scheme>:// link, so a link the server does not recognise
|
|
9
|
+
// (404, 403, not_approved) is not ours: the sign-in keeps waiting, with the
|
|
10
|
+
// same verifier, for the right one.
|
|
11
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
12
|
+
import { parseLoginLink } from './deep-link.mjs';
|
|
13
|
+
import { readCapped } from './resolve.mjs';
|
|
14
|
+
|
|
15
|
+
const SIGN_IN_MS = 5 * 60 * 1000;
|
|
16
|
+
const REDEEM_GAP_MS = 1100; // the server allows one redeem a second per handoff
|
|
17
|
+
const MAX_REDEEMS = 5;
|
|
18
|
+
const MAX_COOKIES = 4;
|
|
19
|
+
const MAX_MAX_AGE = 400 * 24 * 60 * 60;
|
|
20
|
+
const SAME_SITE = { lax: 'lax', strict: 'strict' };
|
|
21
|
+
const COOKIE_NAME = /^(__Host-)?[A-Za-z0-9_]+$/;
|
|
22
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
23
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
|
|
24
|
+
|
|
25
|
+
// A code both the app and the server's confirm page derive from the challenge,
|
|
26
|
+
// so the person can tell the sign-in they confirm in the browser is the one this
|
|
27
|
+
// app started: the first 6 characters of the Crockford base32 of
|
|
28
|
+
// sha256("boule-desktop-pair:" + challenge), 5 bits each, shown as ABC-DEF.
|
|
29
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
30
|
+
export function pairingCode(challenge) {
|
|
31
|
+
const digest = createHash('sha256').update(`boule-desktop-pair:${challenge}`).digest();
|
|
32
|
+
let code = '';
|
|
33
|
+
for (let bit = 0; bit < 30; bit += 5) {
|
|
34
|
+
const window = (digest[bit >> 3] << 8) | digest[(bit >> 3) + 1];
|
|
35
|
+
code += CROCKFORD[(window >> (11 - (bit & 7))) & 31];
|
|
36
|
+
}
|
|
37
|
+
return `${code.slice(0, 3)}-${code.slice(3)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Returns the arguments for session.cookies.set, or throws. The session cookie
|
|
41
|
+
// is HttpOnly and Lax or Strict. On an https origin only a __Host- cookie is
|
|
42
|
+
// accepted: secure, path "/", no domain.
|
|
43
|
+
function checkedCookie(cookie, origin) {
|
|
44
|
+
const { name, value, path, httpOnly, secure, sameSite, maxAge } = cookie ?? {};
|
|
45
|
+
if (
|
|
46
|
+
typeof name !== 'string' ||
|
|
47
|
+
!COOKIE_NAME.test(name) ||
|
|
48
|
+
typeof value !== 'string' ||
|
|
49
|
+
Buffer.byteLength(value) > 4096 ||
|
|
50
|
+
CONTROL.test(value) ||
|
|
51
|
+
value.includes(';')
|
|
52
|
+
)
|
|
53
|
+
throw new Error('Invalid session cookie');
|
|
54
|
+
if (
|
|
55
|
+
httpOnly !== true ||
|
|
56
|
+
typeof secure !== 'boolean' ||
|
|
57
|
+
typeof path !== 'string' ||
|
|
58
|
+
!Object.hasOwn(SAME_SITE, sameSite) ||
|
|
59
|
+
(maxAge !== undefined && !(Number.isFinite(maxAge) && maxAge > 0 && maxAge <= MAX_MAX_AGE))
|
|
60
|
+
)
|
|
61
|
+
throw new Error('Invalid session cookie');
|
|
62
|
+
if (origin.startsWith('https:') && (!name.startsWith('__Host-') || !secure || path !== '/'))
|
|
63
|
+
throw new Error('Invalid session cookie');
|
|
64
|
+
return {
|
|
65
|
+
url: origin,
|
|
66
|
+
name,
|
|
67
|
+
value,
|
|
68
|
+
path,
|
|
69
|
+
httpOnly,
|
|
70
|
+
secure,
|
|
71
|
+
sameSite: SAME_SITE[sameSite],
|
|
72
|
+
...(maxAge === undefined ? {} : { expirationDate: Date.now() / 1000 + maxAge }),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class DesktopLogin {
|
|
77
|
+
#pending;
|
|
78
|
+
// `fetcher` is required and has no default: an app behind Cloudflare Access
|
|
79
|
+
// must send the redeem through the company partition's session.fetch, so the
|
|
80
|
+
// partition's cookies go along (see partitionFetcher in access.mjs).
|
|
81
|
+
constructor({
|
|
82
|
+
scheme,
|
|
83
|
+
origin,
|
|
84
|
+
partition,
|
|
85
|
+
openBrowser,
|
|
86
|
+
fetcher,
|
|
87
|
+
timeoutMs = SIGN_IN_MS,
|
|
88
|
+
redeemGapMs = REDEEM_GAP_MS,
|
|
89
|
+
}) {
|
|
90
|
+
if (typeof fetcher !== 'function') throw new Error('DesktopLogin needs a fetcher');
|
|
91
|
+
Object.assign(this, {
|
|
92
|
+
scheme,
|
|
93
|
+
origin,
|
|
94
|
+
partition,
|
|
95
|
+
openBrowser,
|
|
96
|
+
fetcher,
|
|
97
|
+
timeoutMs,
|
|
98
|
+
redeemGapMs,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
get active() {
|
|
102
|
+
return Boolean(this.#pending);
|
|
103
|
+
}
|
|
104
|
+
// The pairing code of the sign-in in progress, set as soon as start() is called.
|
|
105
|
+
get code() {
|
|
106
|
+
return this.#pending?.code;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Resolves once the cookies are in the partition. Rejects when cancelled,
|
|
110
|
+
// timed out, expired or out of redeem attempts.
|
|
111
|
+
// selectAccount asks the issuer to show its account chooser (after a sign-out).
|
|
112
|
+
async start(email, selectAccount = false) {
|
|
113
|
+
if (this.#pending) throw new Error('Sign-in already active');
|
|
114
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
115
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
116
|
+
const pending = {
|
|
117
|
+
verifier,
|
|
118
|
+
code: pairingCode(challenge),
|
|
119
|
+
cancelled: false,
|
|
120
|
+
error: undefined,
|
|
121
|
+
waiter: undefined,
|
|
122
|
+
};
|
|
123
|
+
this.#pending = pending;
|
|
124
|
+
const nextLink = () =>
|
|
125
|
+
pending.error
|
|
126
|
+
? Promise.reject(pending.error)
|
|
127
|
+
: new Promise((resolve, reject) => {
|
|
128
|
+
pending.waiter = { resolve, reject };
|
|
129
|
+
});
|
|
130
|
+
const timer = setTimeout(() => this.cancel(pending, 'Sign-in timed out'), this.timeoutMs);
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL('/auth/desktop/start', this.origin);
|
|
133
|
+
url.searchParams.set('challenge', challenge);
|
|
134
|
+
if (email) url.searchParams.set('login_hint', email);
|
|
135
|
+
if (selectAccount) url.searchParams.set('prompt', 'select_account');
|
|
136
|
+
await this.openBrowser(url.href);
|
|
137
|
+
let cookies;
|
|
138
|
+
let id;
|
|
139
|
+
let last = 0;
|
|
140
|
+
for (let attempts = 0; !cookies; attempts++) {
|
|
141
|
+
id ??= await nextLink(); // links that arrive while a redeem runs are ignored
|
|
142
|
+
if (attempts >= MAX_REDEEMS) throw new Error('Sign-in was refused');
|
|
143
|
+
await sleep(last + this.redeemGapMs - Date.now());
|
|
144
|
+
if (pending.error) throw pending.error;
|
|
145
|
+
last = Date.now();
|
|
146
|
+
const result = await this.#redeem(id, pending.verifier);
|
|
147
|
+
if (result === 'wrong') id = undefined;
|
|
148
|
+
else if (result !== 'retry')
|
|
149
|
+
cookies = result.map((cookie) => checkedCookie(cookie, this.origin));
|
|
150
|
+
}
|
|
151
|
+
// Cancelled while redeeming: the session was minted but is never installed.
|
|
152
|
+
if (pending.cancelled) throw new Error('Sign-in cancelled');
|
|
153
|
+
for (const cookie of cookies) await this.partition.cookies.set(cookie);
|
|
154
|
+
} finally {
|
|
155
|
+
clearTimeout(timer);
|
|
156
|
+
pending.verifier = '';
|
|
157
|
+
if (this.#pending === pending) this.#pending = undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The cookies, 'wrong' (not our link: wait for another) or 'retry'; throws
|
|
162
|
+
// when the sign-in is over.
|
|
163
|
+
async #redeem(id, verifier) {
|
|
164
|
+
let response;
|
|
165
|
+
try {
|
|
166
|
+
response = await this.fetcher(`${this.origin}/auth/desktop/redeem`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
redirect: 'error',
|
|
169
|
+
signal: AbortSignal.timeout(10000),
|
|
170
|
+
headers: { 'content-type': 'application/json' },
|
|
171
|
+
body: JSON.stringify({ id, verifier }),
|
|
172
|
+
});
|
|
173
|
+
} catch {
|
|
174
|
+
return 'retry';
|
|
175
|
+
}
|
|
176
|
+
if (response.status === 200) {
|
|
177
|
+
const { cookies } = JSON.parse(await readCapped(response, 32 * 1024));
|
|
178
|
+
if (!Array.isArray(cookies) || !cookies.length || cookies.length > MAX_COOKIES)
|
|
179
|
+
throw new Error('Invalid sign-in response');
|
|
180
|
+
return cookies;
|
|
181
|
+
}
|
|
182
|
+
let code;
|
|
183
|
+
try {
|
|
184
|
+
code = JSON.parse(await readCapped(response, 1024)).error;
|
|
185
|
+
} catch {
|
|
186
|
+
/* no code */
|
|
187
|
+
}
|
|
188
|
+
const { status } = response;
|
|
189
|
+
if (status === 401 || status === 410 || (status === 409 && code === 'consumed'))
|
|
190
|
+
throw new Error('Sign-in expired. Start again.');
|
|
191
|
+
if (status === 429 || status >= 500) return 'retry';
|
|
192
|
+
return 'wrong'; // 400, 403, 404, 409 not_approved
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// A deep link. Ignored unless a sign-in is waiting for one. It never ends the
|
|
196
|
+
// sign-in: a link the server refuses just means waiting for another.
|
|
197
|
+
handleDeepLink(value) {
|
|
198
|
+
const waiter = this.#pending?.waiter;
|
|
199
|
+
const id = waiter ? parseLoginLink(value, this.scheme) : undefined;
|
|
200
|
+
if (!id) return false;
|
|
201
|
+
this.#pending.waiter = undefined;
|
|
202
|
+
waiter.resolve(id);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
cancel(pending = this.#pending, reason = 'Sign-in cancelled') {
|
|
207
|
+
if (!pending) return;
|
|
208
|
+
pending.cancelled = true;
|
|
209
|
+
pending.verifier = '';
|
|
210
|
+
pending.error = new Error(reason);
|
|
211
|
+
pending.waiter?.reject(pending.error);
|
|
212
|
+
pending.waiter = undefined;
|
|
213
|
+
if (this.#pending === pending) this.#pending = undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
package/src/menu.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The native menu template. A packaged app has no DevTools and no reload.
|
|
2
|
+
export function menuTemplate({ appName, packaged, onCancel, onSignOut }) {
|
|
3
|
+
const view = packaged
|
|
4
|
+
? {
|
|
5
|
+
label: 'View',
|
|
6
|
+
submenu: [
|
|
7
|
+
{ role: 'resetZoom' },
|
|
8
|
+
{ role: 'zoomIn' },
|
|
9
|
+
{ role: 'zoomOut' },
|
|
10
|
+
{ type: 'separator' },
|
|
11
|
+
{ role: 'togglefullscreen' },
|
|
12
|
+
],
|
|
13
|
+
}
|
|
14
|
+
: { role: 'viewMenu' };
|
|
15
|
+
return [
|
|
16
|
+
{
|
|
17
|
+
label: appName,
|
|
18
|
+
submenu: [
|
|
19
|
+
{ label: 'Cancel sign-in', click: onCancel },
|
|
20
|
+
{ label: 'Sign out', click: onSignOut },
|
|
21
|
+
{ type: 'separator' },
|
|
22
|
+
{ role: 'quit' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
{ role: 'editMenu' },
|
|
26
|
+
view,
|
|
27
|
+
];
|
|
28
|
+
}
|
package/src/resolve.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Finds a company's app. Pure apart from the fetch passed to discover().
|
|
2
|
+
// Origins are https. Plain http for localhost and 127.0.0.1 is accepted only
|
|
3
|
+
// with { allowLocal: true }, which the app sets for unpackaged builds and tests.
|
|
4
|
+
export class NotFoundError extends Error {
|
|
5
|
+
constructor(message = 'Nothing found there.') {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'NotFoundError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
|
12
|
+
const HOST = `(?:${LABEL}\\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]`;
|
|
13
|
+
const DOMAIN = new RegExp(`^${HOST}$`);
|
|
14
|
+
const EMAIL = new RegExp(`^[a-z0-9._%+-]{1,64}@(${HOST})$`, 'i');
|
|
15
|
+
const escapeDots = (value) => value.replaceAll('.', '\\.');
|
|
16
|
+
const MAX_HOST = 253;
|
|
17
|
+
const MAX_BODY = 16 * 1024;
|
|
18
|
+
// Control, bidi and zero-width characters have no place in a company name.
|
|
19
|
+
const HIDDEN = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
|
|
20
|
+
|
|
21
|
+
const isLocal = (hostname) => hostname === 'localhost' || hostname === '127.0.0.1';
|
|
22
|
+
|
|
23
|
+
export const validName = (name) =>
|
|
24
|
+
typeof name === 'string' && Boolean(name.trim()) && name.length <= 100 && !HIDDEN.test(name);
|
|
25
|
+
|
|
26
|
+
// With { hostPrefix: 'boule', sharedHost: { domain: 'oikos.host' } }:
|
|
27
|
+
// ana@Acme.com -> https://boule.acme.com; ana@acme.oikos.host ->
|
|
28
|
+
// https://acme-boule.oikos.host. The shared host is the platform's domain for
|
|
29
|
+
// orgs without one of their own. Anything but a plain address throws.
|
|
30
|
+
export function originForEmail(email, { hostPrefix, sharedHost }) {
|
|
31
|
+
const match = typeof email === 'string' ? EMAIL.exec(email.trim()) : null;
|
|
32
|
+
if (!match || match[1].length > MAX_HOST - 6)
|
|
33
|
+
throw new Error('That is not a work email address.');
|
|
34
|
+
const domain = match[1].toLowerCase();
|
|
35
|
+
const slug = sharedHost
|
|
36
|
+
? new RegExp(`^(${LABEL})\\.${escapeDots(sharedHost.domain)}$`).exec(domain)?.[1]
|
|
37
|
+
: undefined;
|
|
38
|
+
return slug
|
|
39
|
+
? `https://${slug}-${hostPrefix}.${sharedHost.domain}`
|
|
40
|
+
: `https://${hostPrefix}.${domain}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// What somebody typed for a company's address: `Boule.Acme.com/x`
|
|
44
|
+
// gives https://boule.acme.com. A pasted scheme or path is dropped.
|
|
45
|
+
export function originForAddress(input, { allowLocal = false } = {}) {
|
|
46
|
+
const trimmed = typeof input === 'string' ? input.trim() : '';
|
|
47
|
+
const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed)?.[1].toLowerCase();
|
|
48
|
+
let url;
|
|
49
|
+
try {
|
|
50
|
+
url = new URL(scheme ? trimmed : `https://${trimmed}`);
|
|
51
|
+
} catch {
|
|
52
|
+
url = undefined;
|
|
53
|
+
}
|
|
54
|
+
if (!url || (scheme && scheme !== 'https' && scheme !== 'http') || url.username || url.password)
|
|
55
|
+
throw new Error(`${input} is not an address.`);
|
|
56
|
+
if (allowLocal && isLocal(url.hostname)) return `http://${url.host}`;
|
|
57
|
+
if (scheme === 'http' || url.hostname.length > MAX_HOST || !DOMAIN.test(url.hostname))
|
|
58
|
+
throw new Error(`${input} is not an address.`);
|
|
59
|
+
return `https://${url.host}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readCapped(response, max) {
|
|
63
|
+
const reader = response.body?.getReader();
|
|
64
|
+
if (!reader) throw new Error('Empty response');
|
|
65
|
+
const chunks = [];
|
|
66
|
+
let size = 0;
|
|
67
|
+
for (;;) {
|
|
68
|
+
const { done, value } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
size += value.length;
|
|
71
|
+
if (size > max) {
|
|
72
|
+
await reader.cancel();
|
|
73
|
+
throw new Error('Response too large');
|
|
74
|
+
}
|
|
75
|
+
chunks.push(value);
|
|
76
|
+
}
|
|
77
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The name check every well-known file gets unless the app passes its own
|
|
81
|
+
// `validate`, which returns { name } or undefined.
|
|
82
|
+
const defaultValidate = (json) => (validName(json?.name) ? { name: json.name } : undefined);
|
|
83
|
+
|
|
84
|
+
// Confirms an app lives at origin and returns its name. Any failure, from
|
|
85
|
+
// DNS to a redirect to a malformed file, is the same answer: not found.
|
|
86
|
+
export async function discover(
|
|
87
|
+
origin,
|
|
88
|
+
fetcher,
|
|
89
|
+
{ wellKnownPath, validate = defaultValidate, allowLocal = false },
|
|
90
|
+
) {
|
|
91
|
+
try {
|
|
92
|
+
if (originForAddress(origin, { allowLocal }) !== origin) throw new NotFoundError();
|
|
93
|
+
const response = await fetcher(`${origin}${wellKnownPath}`, {
|
|
94
|
+
redirect: 'error',
|
|
95
|
+
signal: AbortSignal.timeout(8000),
|
|
96
|
+
headers: { accept: 'application/json' },
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) throw new NotFoundError();
|
|
99
|
+
const found = validate(JSON.parse(await readCapped(response, MAX_BODY)), origin);
|
|
100
|
+
if (!found || !validName(found.name)) throw new NotFoundError();
|
|
101
|
+
return { ...found, origin };
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw error instanceof NotFoundError ? error : new NotFoundError();
|
|
104
|
+
}
|
|
105
|
+
}
|
package/src/security.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The company window may only show the company's own pages. /auth/ and
|
|
2
|
+
// /callback are for the system browser, never this window. The path is
|
|
3
|
+
// normalised first so %61uth, /AUTH, //auth and \auth are the same as /auth
|
|
4
|
+
// (Boule's rule; Agora's was case-sensitive and did not normalise).
|
|
5
|
+
export function allowedNavigation(value, origin) {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
const path = decodeURIComponent(url.pathname)
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[\\/]+/g, '/');
|
|
11
|
+
return (
|
|
12
|
+
url.origin === origin &&
|
|
13
|
+
!url.username &&
|
|
14
|
+
!url.password &&
|
|
15
|
+
!/^\/(auth|callback)(\/|$)/.test(path)
|
|
16
|
+
);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
// IPC from the welcome page: that window's own top frame, showing welcome.html.
|
|
22
|
+
export function trustedWelcomeSender(event, contents, welcomeUrl) {
|
|
23
|
+
if (contents.isDestroyed?.()) return false;
|
|
24
|
+
return (
|
|
25
|
+
event.sender === contents &&
|
|
26
|
+
event.senderFrame === contents.mainFrame &&
|
|
27
|
+
event.senderFrame?.url.split('#')[0] === welcomeUrl
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
// IPC from the company page: its own top frame, on a page it may navigate to.
|
|
31
|
+
export function trustedSender(event, contents, origin) {
|
|
32
|
+
if (contents.isDestroyed?.()) return false;
|
|
33
|
+
return (
|
|
34
|
+
event.sender === contents &&
|
|
35
|
+
event.senderFrame === contents.mainFrame &&
|
|
36
|
+
event.senderFrame?.origin === origin &&
|
|
37
|
+
allowedNavigation(event.senderFrame.url, origin)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
// The partition denies every permission unless the app's config lists it, and
|
|
41
|
+
// then only for the company's own origin: a page that navigated elsewhere never
|
|
42
|
+
// gets a look. `media` is camera and microphone (Electron says which in the
|
|
43
|
+
// media type); screen sharing is a display-capture request, not a permission
|
|
44
|
+
// here. `permissions` is { notifications, media } from the config.
|
|
45
|
+
export function allowedPermission(permission, requestingOrigin, origin, permissions, mediaTypes) {
|
|
46
|
+
if (requestingOrigin !== origin) return false;
|
|
47
|
+
if (permission === 'notifications') return permissions.notifications;
|
|
48
|
+
return (
|
|
49
|
+
permission === 'media' &&
|
|
50
|
+
permissions.media &&
|
|
51
|
+
mediaTypes?.length > 0 &&
|
|
52
|
+
mediaTypes.every((type) => type === 'audio' || type === 'video')
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
export function permissionCheckHandler(origin, permissions) {
|
|
56
|
+
return (_contents, permission, requestingOrigin, details) =>
|
|
57
|
+
allowedPermission(
|
|
58
|
+
permission,
|
|
59
|
+
requestingOrigin,
|
|
60
|
+
origin,
|
|
61
|
+
permissions,
|
|
62
|
+
details?.mediaType && [details.mediaType],
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
export function permissionRequestHandler(origin, permissions) {
|
|
66
|
+
return (_contents, permission, callback, details) => {
|
|
67
|
+
let requestingOrigin;
|
|
68
|
+
try {
|
|
69
|
+
requestingOrigin = new URL(details?.requestingUrl ?? '').origin;
|
|
70
|
+
} catch {
|
|
71
|
+
requestingOrigin = undefined;
|
|
72
|
+
}
|
|
73
|
+
callback(
|
|
74
|
+
allowedPermission(permission, requestingOrigin, origin, permissions, details?.mediaTypes),
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// `allowed` decides which URLs the contents, and every frame in it, may ever
|
|
79
|
+
// navigate to. `onBlocked` hears each refused top-level navigation or redirect.
|
|
80
|
+
export function secureContents(contents, allowed, onBlocked = () => {}) {
|
|
81
|
+
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
|
82
|
+
const top = (event, url) => {
|
|
83
|
+
if (!allowed(url)) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
onBlocked(url);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
contents.on('will-navigate', top);
|
|
89
|
+
contents.on('will-redirect', top);
|
|
90
|
+
contents.on('will-frame-navigate', (details) => {
|
|
91
|
+
if (allowed(details.url)) return;
|
|
92
|
+
details.preventDefault();
|
|
93
|
+
if (details.isMainFrame) onBlocked(details.url);
|
|
94
|
+
});
|
|
95
|
+
contents.on('will-attach-webview', (event) => event.preventDefault());
|
|
96
|
+
}
|