@hosty-sdk/app 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activity-react.d.ts +5 -1
- package/dist/activity-react.js +34 -11
- package/dist/app-code.d.ts +6 -0
- package/dist/app-code.js +80 -0
- package/dist/browser-auth.d.ts +44 -3
- package/dist/browser-auth.js +463 -27
- package/dist/embedder.d.ts +2 -1
- package/dist/embedder.js +6 -5
- package/dist/index.d.ts +12 -12
- package/dist/index.js +11 -10
- package/dist/react.d.ts +2 -1
- package/dist/react.js +155 -39
- package/dist/server.d.ts +6 -4
- package/dist/server.js +24 -8
- package/package.json +6 -1
package/dist/browser-auth.js
CHANGED
|
@@ -1,12 +1,262 @@
|
|
|
1
|
-
|
|
1
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
2
|
+
function isValidCodeVerifier(value) {
|
|
3
|
+
return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value);
|
|
4
|
+
}
|
|
5
|
+
export const APP_AUTH_ATTEMPT_PREFIX = "hosty.auth.attempt:";
|
|
6
|
+
const MINIMUM_PROTOCOL_PREFIX = "hosty.auth.protocol:";
|
|
7
|
+
const ATTEMPT_LIFETIME = 300_000;
|
|
8
|
+
const MAX_ATTEMPTS = 16;
|
|
9
|
+
const protocolMinimums = new Set();
|
|
10
|
+
function base64url(bytes) {
|
|
11
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
12
|
+
}
|
|
13
|
+
export function appCodeChallenge(verifier) {
|
|
14
|
+
if (!isValidCodeVerifier(verifier))
|
|
15
|
+
throw new Error("The sign-in proof is invalid.");
|
|
16
|
+
return base64url(sha256(Uint8Array.from(verifier, character => character.charCodeAt(0))));
|
|
17
|
+
}
|
|
18
|
+
export function acceptAppAuthProtocol(coreOrigin, protocol) {
|
|
19
|
+
const key = `${MINIMUM_PROTOCOL_PREFIX}${coreOrigin}`;
|
|
20
|
+
try {
|
|
21
|
+
if (window.sessionStorage.getItem(key) === "2")
|
|
22
|
+
protocolMinimums.add(coreOrigin);
|
|
23
|
+
}
|
|
24
|
+
catch { /* Memory minimum still applies. */ }
|
|
25
|
+
if (protocol === 2) {
|
|
26
|
+
protocolMinimums.add(coreOrigin);
|
|
27
|
+
try {
|
|
28
|
+
window.sessionStorage.setItem(key, "2");
|
|
29
|
+
}
|
|
30
|
+
catch { /* Keep the minimum in memory. */ }
|
|
31
|
+
return 2;
|
|
32
|
+
}
|
|
33
|
+
return protocol === 1 && !protocolMinimums.has(coreOrigin) ? 1 : null;
|
|
34
|
+
}
|
|
35
|
+
export function createAppAuthAttempt(openUrl, mode, protocol) {
|
|
36
|
+
const url = new URL(openUrl);
|
|
37
|
+
const match = /^\/api\/apps\/([^/]+)\/open$/.exec(url.pathname);
|
|
38
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || !match)
|
|
39
|
+
throw new Error("Hosty sign-in URL is invalid.");
|
|
40
|
+
if (acceptAppAuthProtocol(url.origin, protocol) !== protocol)
|
|
41
|
+
throw new Error("Hosty sign-in protocol could not be verified.");
|
|
42
|
+
const redirect = new URL(url.searchParams.get("redirectUri") ?? "");
|
|
43
|
+
if (redirect.origin !== window.location.origin || redirect.username || redirect.password || redirect.hash)
|
|
44
|
+
throw new Error("Hosty sign-in must return to this app.");
|
|
45
|
+
const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
46
|
+
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
+
redirect.searchParams.delete("code");
|
|
48
|
+
redirect.searchParams.delete("error");
|
|
49
|
+
redirect.searchParams.set("state", state);
|
|
50
|
+
return { state, codeVerifier, codeChallenge: appCodeChallenge(codeVerifier), appId: decodeURIComponent(match[1]),
|
|
51
|
+
coreOrigin: url.origin, redirectUri: redirect.href, createdAt: Date.now(), mode, protocol };
|
|
52
|
+
}
|
|
53
|
+
function storedAttempts() {
|
|
54
|
+
const keys = [];
|
|
55
|
+
const storage = window.sessionStorage;
|
|
56
|
+
for (let index = 0; index < storage.length; index++) {
|
|
57
|
+
const key = storage.key(index);
|
|
58
|
+
if (!key?.startsWith(APP_AUTH_ATTEMPT_PREFIX))
|
|
59
|
+
continue;
|
|
60
|
+
try {
|
|
61
|
+
const record = JSON.parse(storage.getItem(key) ?? "null");
|
|
62
|
+
if (typeof record?.createdAt !== "number" || record.createdAt > Date.now() || Date.now() - record.createdAt >= ATTEMPT_LIFETIME) {
|
|
63
|
+
storage.removeItem(key);
|
|
64
|
+
index--;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
storage.removeItem(key);
|
|
70
|
+
index--;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
keys.push(key);
|
|
74
|
+
}
|
|
75
|
+
return keys;
|
|
76
|
+
}
|
|
77
|
+
/** Do not leave the app document unless this exact proof survives a storage round trip. */
|
|
78
|
+
export function persistAppAuthAttempt(attempt) {
|
|
79
|
+
const key = `${APP_AUTH_ATTEMPT_PREFIX}${attempt.state}`;
|
|
80
|
+
try {
|
|
81
|
+
if (storedAttempts().length >= MAX_ATTEMPTS)
|
|
82
|
+
return false;
|
|
83
|
+
const value = JSON.stringify(attempt);
|
|
84
|
+
window.sessionStorage.setItem(key, value);
|
|
85
|
+
if (window.sessionStorage.getItem(key) === value)
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch { /* The explicit popup can keep a proof in memory instead. */ }
|
|
89
|
+
try {
|
|
90
|
+
window.sessionStorage.removeItem(key);
|
|
91
|
+
}
|
|
92
|
+
catch { /* Storage remains blocked. */ }
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
function callbackLocation(url) {
|
|
96
|
+
const copy = new URL(url);
|
|
97
|
+
for (const name of ["code", "state", "error", "hosty_launch"])
|
|
98
|
+
copy.searchParams.delete(name);
|
|
99
|
+
copy.hash = "";
|
|
100
|
+
copy.searchParams.sort();
|
|
101
|
+
return copy.href;
|
|
102
|
+
}
|
|
103
|
+
/** Correlation is local; URL fields never supply a verifier or a pending attempt. */
|
|
104
|
+
// Only the recovery bridge opts in to classify and discard an obsolete local v1
|
|
105
|
+
// record. Such a record must never be exchanged after v2 has already been observed.
|
|
106
|
+
export function takeAppAuthAttempt(callback, allowLegacyUpgrade = false) {
|
|
107
|
+
const states = callback.searchParams.getAll("state");
|
|
108
|
+
const state = states[0];
|
|
109
|
+
if (!state || !/^[a-f0-9]{64}$/.test(state) || states.some(value => value !== state))
|
|
110
|
+
return null;
|
|
111
|
+
const key = `${APP_AUTH_ATTEMPT_PREFIX}${state}`;
|
|
112
|
+
try {
|
|
113
|
+
const value = window.sessionStorage.getItem(key);
|
|
114
|
+
if (!value)
|
|
115
|
+
return null;
|
|
116
|
+
window.sessionStorage.removeItem(key);
|
|
117
|
+
const attempt = JSON.parse(value);
|
|
118
|
+
if (attempt.state !== state || !isValidCodeVerifier(attempt.codeVerifier) ||
|
|
119
|
+
attempt.codeChallenge !== appCodeChallenge(attempt.codeVerifier) ||
|
|
120
|
+
![1, 2].includes(attempt.protocol) || !["standalone", "silent", "native"].includes(attempt.mode) ||
|
|
121
|
+
typeof attempt.appId !== "string" || !attempt.appId ||
|
|
122
|
+
typeof attempt.createdAt !== "number" || attempt.createdAt > Date.now() || Date.now() - attempt.createdAt >= ATTEMPT_LIFETIME ||
|
|
123
|
+
new URL(attempt.redirectUri).origin !== window.location.origin || callbackLocation(new URL(attempt.redirectUri)) !== callbackLocation(callback) ||
|
|
124
|
+
new URL(attempt.coreOrigin).origin !== attempt.coreOrigin ||
|
|
125
|
+
(acceptAppAuthProtocol(attempt.coreOrigin, attempt.protocol) !== attempt.protocol && !(allowLegacyUpgrade && attempt.protocol === 1)))
|
|
126
|
+
return null;
|
|
127
|
+
return attempt;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export function clearAppAuthAttempts() {
|
|
134
|
+
if (typeof window === "undefined")
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
for (const key of storedAttempts())
|
|
138
|
+
window.sessionStorage.removeItem(key);
|
|
139
|
+
}
|
|
140
|
+
catch { /* Nothing can navigate with inaccessible proof. */ }
|
|
141
|
+
}
|
|
142
|
+
export function appAuthNavigationUrl(attempt) {
|
|
143
|
+
const url = new URL(`/api/apps/${encodeURIComponent(attempt.appId)}/open`, attempt.coreOrigin);
|
|
144
|
+
for (const [name, value] of Object.entries(appAuthFields(attempt)))
|
|
145
|
+
url.searchParams.set(name, value);
|
|
146
|
+
return url.href;
|
|
147
|
+
}
|
|
148
|
+
function appAuthFields(attempt) {
|
|
149
|
+
return { redirectUri: attempt.redirectUri, state: attempt.state, codeChallenge: attempt.codeChallenge, codeChallengeMethod: "S256",
|
|
150
|
+
...(attempt.mode === "silent" ? { prompt: "none" } : {}),
|
|
151
|
+
...(attempt.mode === "popup" ? { responseMode: "web_message" } : {}) };
|
|
152
|
+
}
|
|
153
|
+
/** Constant document and DOM-built fields prevent a public URL from laundering a challenge. */
|
|
154
|
+
export function submitAppAuthIntent(attempt, doc = document) {
|
|
155
|
+
doc.open();
|
|
156
|
+
doc.write('<!doctype html><html><head><meta charset="utf-8"><meta name="referrer" content="origin"><title>Connecting to Hosty</title></head><body></body></html>');
|
|
157
|
+
doc.close();
|
|
158
|
+
const policy = doc.createElement("meta");
|
|
159
|
+
policy.httpEquiv = "Content-Security-Policy";
|
|
160
|
+
// CSP host sources cannot represent IPv6 literals; the form target remains fixed to Core.
|
|
161
|
+
// Other origins permit Core and this frozen app origin through the return redirect chain.
|
|
162
|
+
const core = new URL(attempt.coreOrigin);
|
|
163
|
+
const formAction = core.hostname.startsWith("[") ? "" : `form-action ${core.origin} 'self'; `;
|
|
164
|
+
policy.content = `default-src 'none'; ${formAction}base-uri 'none'`;
|
|
165
|
+
doc.head.append(policy);
|
|
166
|
+
const form = doc.createElement("form");
|
|
167
|
+
form.method = "POST";
|
|
168
|
+
form.action = new URL(`/api/apps/${encodeURIComponent(attempt.appId)}/sign-in-intent`, attempt.coreOrigin).href;
|
|
169
|
+
for (const [name, value] of Object.entries(appAuthFields(attempt))) {
|
|
170
|
+
const input = doc.createElement("input");
|
|
171
|
+
input.type = "hidden";
|
|
172
|
+
input.name = name;
|
|
173
|
+
input.value = value;
|
|
174
|
+
form.append(input);
|
|
175
|
+
}
|
|
176
|
+
const notice = doc.createElement("p");
|
|
177
|
+
notice.textContent = "Connecting to Hosty…";
|
|
178
|
+
doc.body.append(notice, form);
|
|
179
|
+
form.submit();
|
|
180
|
+
}
|
|
181
|
+
/** App-only credentials. Embedded documents keep them for this tab on the app's own origin. */
|
|
2
182
|
let grant = null;
|
|
183
|
+
let grantRevision = 0;
|
|
3
184
|
let recoveryPending = false;
|
|
185
|
+
const popupCancellations = new Set();
|
|
4
186
|
export const APP_SESSION_ENDED = "hosty:app-session-ended";
|
|
187
|
+
export const NATIVE_APP_AUTH_RESULT = "hosty:native-auth-result";
|
|
188
|
+
export const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
|
|
189
|
+
function isEmbedded() {
|
|
190
|
+
try {
|
|
191
|
+
return window.self !== window.top;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
/** Restore before the first identity probe; standalone documents use their first-party cookie. */
|
|
198
|
+
export function restoreAppGrant() {
|
|
199
|
+
if (typeof window === "undefined")
|
|
200
|
+
return false;
|
|
201
|
+
if (grant?.origin === window.location.origin)
|
|
202
|
+
return true;
|
|
203
|
+
if (!isEmbedded())
|
|
204
|
+
return false;
|
|
205
|
+
try {
|
|
206
|
+
const token = window.sessionStorage.getItem(APP_GRANT_STORAGE_KEY);
|
|
207
|
+
if (token) {
|
|
208
|
+
grant = { token, origin: window.location.origin };
|
|
209
|
+
grantRevision++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Blocked storage leaves the in-memory transport available.
|
|
214
|
+
}
|
|
215
|
+
return grant?.origin === window.location.origin;
|
|
216
|
+
}
|
|
5
217
|
export function rememberAppGrant(token) {
|
|
6
218
|
grant = { token, origin: window.location.origin };
|
|
219
|
+
grantRevision++;
|
|
220
|
+
recoveryPending = false;
|
|
221
|
+
if (isEmbedded()) {
|
|
222
|
+
try {
|
|
223
|
+
window.sessionStorage.setItem(APP_GRANT_STORAGE_KEY, token);
|
|
224
|
+
}
|
|
225
|
+
catch { /* In-memory fallback. */ }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export function forgetAppGrant() {
|
|
229
|
+
if (!grant)
|
|
230
|
+
grantRevision++;
|
|
231
|
+
discardAppGrant();
|
|
232
|
+
clearAppAuthAttempts();
|
|
233
|
+
for (const cancel of [...popupCancellations])
|
|
234
|
+
cancel();
|
|
235
|
+
cancelActivityRenewal();
|
|
236
|
+
}
|
|
237
|
+
function discardAppGrant() {
|
|
238
|
+
if (grant)
|
|
239
|
+
grantRevision++;
|
|
240
|
+
grant = null;
|
|
7
241
|
recoveryPending = false;
|
|
242
|
+
if (typeof window !== "undefined") {
|
|
243
|
+
try {
|
|
244
|
+
window.sessionStorage.removeItem(APP_GRANT_STORAGE_KEY);
|
|
245
|
+
}
|
|
246
|
+
catch { /* In-memory fallback. */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Snapshot the grant generation so older probes cannot invalidate a renewed identity. */
|
|
250
|
+
export function appGrantRevision() { return grantRevision; }
|
|
251
|
+
/** Activity expiry keeps identity; only explicit identity rejection discards the credential. */
|
|
252
|
+
export function forgetRejectedAppGrant(code, expectedRevision = grantRevision) {
|
|
253
|
+
if (expectedRevision !== grantRevision)
|
|
254
|
+
return false;
|
|
255
|
+
if (typeof code !== "string" || !["token_invalid", "token_revoked", "token_expired", "token_app_mismatch"].includes(code))
|
|
256
|
+
return false;
|
|
257
|
+
discardAppGrant();
|
|
258
|
+
return true;
|
|
8
259
|
}
|
|
9
|
-
export function forgetAppGrant() { grant = null; recoveryPending = false; }
|
|
10
260
|
export function appSessionActive() { recoveryPending = false; }
|
|
11
261
|
/** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
|
|
12
262
|
export async function appFetch(input, init = {}, recover = true) {
|
|
@@ -20,11 +270,21 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
20
270
|
if (current)
|
|
21
271
|
headers.set("authorization", `Bearer ${current.token}`);
|
|
22
272
|
const response = await fetch(input, { ...init, headers, credentials: "same-origin", redirect: "error" });
|
|
23
|
-
if (response.status === 401
|
|
273
|
+
if ((response.status === 401 || response.status === 403) && grant === current && !init.signal?.aborted) {
|
|
274
|
+
const revision = grantRevision;
|
|
24
275
|
const body = await response.clone().json().catch(() => null);
|
|
276
|
+
if (grant !== current || grantRevision !== revision || init.signal?.aborted)
|
|
277
|
+
return response;
|
|
278
|
+
// Identity probes disable recovery, but a rejected grant must still be forgotten. Activity
|
|
279
|
+
// expiry is different: the identity remains valid and the popup renews the same session.
|
|
280
|
+
const rejected = forgetRejectedAppGrant(body?.code);
|
|
281
|
+
if (response.status !== 401)
|
|
282
|
+
return response;
|
|
283
|
+
if (!rejected && recover && body?.code !== "reauth_required" && current)
|
|
284
|
+
discardAppGrant();
|
|
285
|
+
if (!recover)
|
|
286
|
+
return response;
|
|
25
287
|
if (activityConfig) {
|
|
26
|
-
if (body?.code !== "reauth_required" && current)
|
|
27
|
-
grant = null;
|
|
28
288
|
const renewed = waitForActivity(init.signal);
|
|
29
289
|
window.dispatchEvent(new Event(APP_SESSION_ENDED));
|
|
30
290
|
// A response may arrive while the originating click still has browser activation.
|
|
@@ -35,45 +295,153 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
35
295
|
return appFetch(input, init, false);
|
|
36
296
|
}
|
|
37
297
|
else if (!recoveryPending) {
|
|
38
|
-
if (current)
|
|
39
|
-
grant = null;
|
|
40
298
|
recoveryPending = true;
|
|
41
299
|
window.dispatchEvent(new Event(APP_SESSION_ENDED));
|
|
42
300
|
}
|
|
43
301
|
}
|
|
44
302
|
return response;
|
|
45
303
|
}
|
|
46
|
-
/**
|
|
47
|
-
export function
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
304
|
+
/** Refresh only the app-local, server-verified metadata for this frozen app/Core pair. */
|
|
305
|
+
export async function refreshAppAuthProtocol(openUrl, probePath = "/api/auth/identity", signal) {
|
|
306
|
+
const controller = new AbortController();
|
|
307
|
+
const abort = () => controller.abort();
|
|
308
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
309
|
+
if (signal?.aborted)
|
|
310
|
+
controller.abort();
|
|
311
|
+
const timer = window.setTimeout(abort, 4_000);
|
|
312
|
+
try {
|
|
313
|
+
const open = new URL(openUrl);
|
|
314
|
+
const match = /^\/api\/apps\/([^/]+)\/open$/.exec(open.pathname);
|
|
315
|
+
if (!match || !["http:", "https:"].includes(open.protocol) || open.username || open.password)
|
|
316
|
+
return null;
|
|
317
|
+
const response = await appFetch(probePath, { cache: "no-store", signal: controller.signal }, false);
|
|
318
|
+
if (![200, 401].includes(response.status) || !response.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
|
|
319
|
+
return null;
|
|
320
|
+
const body = await response.json();
|
|
321
|
+
const recovery = body?.recovery;
|
|
322
|
+
if (recovery?.appId !== decodeURIComponent(match[1]) || typeof recovery.corePublicOrigin !== "string")
|
|
323
|
+
return null;
|
|
324
|
+
const core = new URL(recovery.corePublicOrigin);
|
|
325
|
+
if (core.origin !== open.origin || core.username || core.password || core.pathname !== "/" || core.search || core.hash)
|
|
326
|
+
return null;
|
|
327
|
+
if (controller.signal.aborted)
|
|
328
|
+
return null;
|
|
329
|
+
return acceptAppAuthProtocol(open.origin, recovery.appAuthProtocol === 2 ? 2 : recovery.appAuthProtocol === 1 ? 1 : null);
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
window.clearTimeout(timer);
|
|
336
|
+
signal?.removeEventListener("abort", abort);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Must be called directly from a user gesture. Native hosts intercept before any request. */
|
|
340
|
+
export function openAppSignIn(openUrl, signal, appAuthProtocol = 2, native = false, probePath = "/api/auth/identity") {
|
|
341
|
+
let initialAttempt;
|
|
342
|
+
try {
|
|
343
|
+
initialAttempt = createAppAuthAttempt(openUrl, native ? "native" : "popup", appAuthProtocol);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
return Promise.reject(error);
|
|
347
|
+
}
|
|
348
|
+
if (native)
|
|
349
|
+
return openNativeAppSignIn(initialAttempt, signal);
|
|
54
350
|
return new Promise((resolve, reject) => {
|
|
351
|
+
let attempt = initialAttempt;
|
|
352
|
+
initialAttempt = null;
|
|
55
353
|
let popup = null;
|
|
56
354
|
let settled = false;
|
|
355
|
+
let upgraded = false;
|
|
356
|
+
let restartPoll;
|
|
357
|
+
const refreshController = new AbortController();
|
|
57
358
|
const finish = (code, error) => {
|
|
58
359
|
if (settled)
|
|
59
360
|
return;
|
|
60
361
|
settled = true;
|
|
362
|
+
const proof = code && attempt ? { code, codeVerifier: attempt.codeVerifier } : null;
|
|
363
|
+
attempt = null;
|
|
364
|
+
refreshController.abort();
|
|
61
365
|
window.removeEventListener("message", receive);
|
|
62
366
|
signal?.removeEventListener("abort", abort);
|
|
367
|
+
popupCancellations.delete(abort);
|
|
63
368
|
window.clearTimeout(timer);
|
|
64
369
|
clearInterval(closedCheck);
|
|
370
|
+
clearInterval(restartPoll);
|
|
65
371
|
popup?.close();
|
|
66
|
-
if (
|
|
67
|
-
resolve(
|
|
372
|
+
if (proof)
|
|
373
|
+
resolve(proof);
|
|
68
374
|
else
|
|
69
375
|
reject(new Error(error ?? "Sign-in was cancelled."));
|
|
70
376
|
};
|
|
377
|
+
const restart = async () => {
|
|
378
|
+
// The correlated Core refusal carries no authority. The app server must verify v2
|
|
379
|
+
// before the existing gesture-opened window receives an entirely fresh attempt.
|
|
380
|
+
attempt = null;
|
|
381
|
+
if (await refreshAppAuthProtocol(openUrl, probePath, refreshController.signal) !== 2) {
|
|
382
|
+
if (!settled)
|
|
383
|
+
finish(undefined, "Hosty sign-in protocol could not be verified. Please try again.");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (settled || !popup || popup.closed)
|
|
387
|
+
return;
|
|
388
|
+
try {
|
|
389
|
+
popup.location.href = "about:blank";
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
finish(undefined, "The app could not restart Hosty sign-in. Please try again.");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
const deadline = Date.now() + 5_000;
|
|
396
|
+
const submit = () => {
|
|
397
|
+
if (settled)
|
|
398
|
+
return;
|
|
399
|
+
if (!popup || popup.closed) {
|
|
400
|
+
finish();
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
let doc;
|
|
404
|
+
try {
|
|
405
|
+
// Access is possible only after the app-initiated blank navigation restores
|
|
406
|
+
// this app's origin. Never write an intent into the old Core document.
|
|
407
|
+
doc = popup.document;
|
|
408
|
+
if (doc.URL !== "about:blank")
|
|
409
|
+
throw new Error("Popup is still navigating.");
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
if (Date.now() >= deadline)
|
|
413
|
+
finish(undefined, "The app could not restart Hosty sign-in. Please try again.");
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
clearInterval(restartPoll);
|
|
417
|
+
try {
|
|
418
|
+
attempt = createAppAuthAttempt(openUrl, "popup", 2);
|
|
419
|
+
submitAppAuthIntent(attempt, doc);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
finish(undefined, "The app could not restart Hosty sign-in. Please try again.");
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
restartPoll = setInterval(submit, 20);
|
|
426
|
+
submit();
|
|
427
|
+
};
|
|
71
428
|
const receive = (event) => {
|
|
72
|
-
if (!popup || event.source !== popup || event.origin !==
|
|
429
|
+
if (!popup || !attempt || event.source !== popup || event.origin !== attempt.coreOrigin)
|
|
73
430
|
return;
|
|
74
431
|
const data = event.data;
|
|
75
|
-
if (data?.type !== "hosty:app-auth-code" || data.state !== state
|
|
76
|
-
|
|
432
|
+
if (data?.type !== "hosty:app-auth-code" || data.state !== attempt.state)
|
|
433
|
+
return;
|
|
434
|
+
if (typeof data.error === "string") {
|
|
435
|
+
if (data.error === "protocol_required" && attempt.protocol === 1 && !upgraded &&
|
|
436
|
+
Object.keys(data).length === 3 && data.code === undefined) {
|
|
437
|
+
upgraded = true;
|
|
438
|
+
void restart();
|
|
439
|
+
}
|
|
440
|
+
else
|
|
441
|
+
finish(undefined, "Hosty could not authorize this sign-in. Please try again.");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (typeof data.code !== "string" || !data.code || data.code.length > 4096)
|
|
77
445
|
return;
|
|
78
446
|
finish(data.code);
|
|
79
447
|
};
|
|
@@ -82,19 +450,80 @@ export function openAppSignIn(openUrl, signal) {
|
|
|
82
450
|
const closedCheck = setInterval(() => { if (popup?.closed)
|
|
83
451
|
finish(); }, 500);
|
|
84
452
|
window.addEventListener("message", receive);
|
|
453
|
+
popupCancellations.add(abort);
|
|
85
454
|
signal?.addEventListener("abort", abort, { once: true });
|
|
86
455
|
if (signal?.aborted) {
|
|
87
456
|
finish();
|
|
88
457
|
return;
|
|
89
458
|
}
|
|
90
|
-
|
|
91
|
-
|
|
459
|
+
// No await precedes window.open: browser activation belongs to this exact app gesture.
|
|
460
|
+
popup = window.open(appAuthProtocol === 1 ? appAuthNavigationUrl(attempt) : "about:blank", "_blank", "popup,width=520,height=700");
|
|
461
|
+
if (!popup) {
|
|
92
462
|
finish(undefined, "Allow the sign-in popup and try again.");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (appAuthProtocol === 2) {
|
|
466
|
+
try {
|
|
467
|
+
submitAppAuthIntent(attempt, popup.document);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
finish(undefined, "The app could not open the Hosty sign-in form. Please try again.");
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
/** The native host preserves this document and returns only a correlated public result. */
|
|
476
|
+
function openNativeAppSignIn(attempt, signal) {
|
|
477
|
+
return new Promise((resolve, reject) => {
|
|
478
|
+
let settled = false;
|
|
479
|
+
const finish = (code, error) => {
|
|
480
|
+
if (settled)
|
|
481
|
+
return;
|
|
482
|
+
settled = true;
|
|
483
|
+
window.removeEventListener(NATIVE_APP_AUTH_RESULT, receive);
|
|
484
|
+
signal?.removeEventListener("abort", abort);
|
|
485
|
+
popupCancellations.delete(abort);
|
|
486
|
+
window.clearTimeout(timer);
|
|
487
|
+
if (code)
|
|
488
|
+
resolve({ code, codeVerifier: attempt.codeVerifier });
|
|
489
|
+
else
|
|
490
|
+
reject(new Error(error ?? "Sign-in was cancelled."));
|
|
491
|
+
};
|
|
492
|
+
const receive = (event) => {
|
|
493
|
+
const data = event.detail;
|
|
494
|
+
if (!data || data.state !== attempt.state)
|
|
495
|
+
return;
|
|
496
|
+
if (data.error && typeof data.error.code === "string") {
|
|
497
|
+
finish(undefined, "Hosty could not authorize this sign-in. Please try again.");
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (typeof data.code !== "string" || !data.code || data.code.length > 4096)
|
|
501
|
+
return;
|
|
502
|
+
finish(data.code);
|
|
503
|
+
};
|
|
504
|
+
const abort = () => finish();
|
|
505
|
+
const timer = window.setTimeout(() => finish(undefined, "Sign-in timed out. Please try again."), 180_000);
|
|
506
|
+
window.addEventListener(NATIVE_APP_AUTH_RESULT, receive);
|
|
507
|
+
popupCancellations.add(abort);
|
|
508
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
509
|
+
if (signal?.aborted) {
|
|
510
|
+
finish();
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const url = new URL(appAuthNavigationUrl(attempt));
|
|
514
|
+
url.searchParams.set("responseMode", "web_message");
|
|
515
|
+
try {
|
|
516
|
+
window.location.assign(url.href);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
finish(undefined, "The native host could not open Hosty sign-in. Please try again.");
|
|
520
|
+
}
|
|
93
521
|
});
|
|
94
522
|
}
|
|
95
523
|
export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
|
|
96
524
|
let activityConfig = null;
|
|
97
525
|
let renewal = null;
|
|
526
|
+
let renewalController = null;
|
|
98
527
|
const waiting = new Set();
|
|
99
528
|
export function configureAppActivity(config) {
|
|
100
529
|
activityConfig = config;
|
|
@@ -109,7 +538,7 @@ export function updateAppActivity(activeUntil, activityRequired) {
|
|
|
109
538
|
activityConfig.activityRequired = activityRequired;
|
|
110
539
|
}
|
|
111
540
|
}
|
|
112
|
-
export function cancelActivityRenewal() { for (const done of [...waiting])
|
|
541
|
+
export function cancelActivityRenewal() { renewalController?.abort(); for (const done of [...waiting])
|
|
113
542
|
done(false); }
|
|
114
543
|
function waitForActivity(signal) {
|
|
115
544
|
return new Promise(resolve => {
|
|
@@ -129,9 +558,16 @@ export function renewAppActivity() {
|
|
|
129
558
|
const config = activityConfig;
|
|
130
559
|
if (!config)
|
|
131
560
|
return Promise.reject(new Error("App recovery is not configured."));
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
561
|
+
const controller = new AbortController();
|
|
562
|
+
const revision = grantRevision;
|
|
563
|
+
renewalController = controller;
|
|
564
|
+
const popup = openAppSignIn(config.openUrl, controller.signal, config.appAuthProtocol, config.native, config.probePath);
|
|
565
|
+
renewal = popup.then(async (proof) => {
|
|
566
|
+
if (config.appAuthProtocol === 1 && acceptAppAuthProtocol(new URL(config.openUrl).origin, 1) === null)
|
|
567
|
+
config.appAuthProtocol = 2;
|
|
568
|
+
const result = await config.exchangeCode(proof);
|
|
569
|
+
if (controller.signal.aborted || activityConfig !== config || revision !== grantRevision)
|
|
570
|
+
throw new Error("Sign-in was cancelled.");
|
|
135
571
|
if (result.accessToken)
|
|
136
572
|
rememberAppGrant(result.accessToken);
|
|
137
573
|
config.activeUntil = result.activeUntil;
|
|
@@ -139,7 +575,7 @@ export function renewAppActivity() {
|
|
|
139
575
|
for (const done of [...waiting])
|
|
140
576
|
done(true);
|
|
141
577
|
window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
|
|
142
|
-
}).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; });
|
|
578
|
+
}).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; renewalController = null; });
|
|
143
579
|
return renewal;
|
|
144
580
|
}
|
|
145
581
|
export function appActivityNeedsRenewal(now = Date.now()) {
|
package/dist/embedder.d.ts
CHANGED
|
@@ -13,7 +13,8 @@ export type AuthRequiredMessage = EmbedderMessage;
|
|
|
13
13
|
* - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
|
|
14
14
|
* - the payload's `appId` must match the app the embedder actually mounted there.
|
|
15
15
|
* No credential crosses the boundary in either direction: the payload is only the app id,
|
|
16
|
-
* and the
|
|
16
|
+
* and the app owns its direct sign-in with Core. This parser gives the embedder no authority
|
|
17
|
+
* to issue or redeem credentials for the app.
|
|
17
18
|
*/
|
|
18
19
|
export declare function parseActiveFrameAuthRequired(event: AuthRequiredMessage, activeFrameWindow: unknown, activeFrameUrl: string, expectedAppId: string): boolean;
|
|
19
20
|
/** A verified request, and the one thing its payload carries. */
|
package/dist/embedder.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// functions plus a small rate limiter — the embedder wires them to its own DOM, its own "open
|
|
4
4
|
// this app" flow, and its own Core session.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Authentication belongs to the app frame: it restores its own per-tab grant, tries a silent
|
|
7
|
+
// Core navigation on first load, and opens its own Core popup when interaction is required.
|
|
8
|
+
// An embedder may observe a verified `hosty:auth-required` intent, but never carries or
|
|
9
|
+
// redeems another app's code or grant.
|
|
10
10
|
//
|
|
11
11
|
// Delegated tokens, in one sentence: on a VERIFIED `hosty:request-delegated-token` from an app
|
|
12
12
|
// you have decided to grant one, mint a token from Core with your own session and post it back
|
|
@@ -21,7 +21,8 @@ import { THEME_PARAM, THEME_PREFERENCE_PARAM } from "./theme.js";
|
|
|
21
21
|
* - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
|
|
22
22
|
* - the payload's `appId` must match the app the embedder actually mounted there.
|
|
23
23
|
* No credential crosses the boundary in either direction: the payload is only the app id,
|
|
24
|
-
* and the
|
|
24
|
+
* and the app owns its direct sign-in with Core. This parser gives the embedder no authority
|
|
25
|
+
* to issue or redeem credentials for the app.
|
|
25
26
|
*/
|
|
26
27
|
export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFrameUrl, expectedAppId) {
|
|
27
28
|
if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
|