@hosty-sdk/app 0.20.0 → 0.21.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 +7 -0
- 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 +37 -3
- package/dist/browser-auth.js +391 -22
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -3
- package/dist/install.d.ts +1 -1
- package/dist/react.d.ts +3 -1
- package/dist/react.js +121 -78
- package/dist/server.d.ts +6 -4
- package/dist/server.js +24 -8
- package/package.json +12 -7
package/README.md
CHANGED
|
@@ -25,6 +25,13 @@ Minimal Next.js wiring:
|
|
|
25
25
|
import { AppIdentityBridge } from "@hosty-sdk/app/react";
|
|
26
26
|
// wrap protected client content: <AppIdentityBridge>{children}</AppIdentityBridge>
|
|
27
27
|
|
|
28
|
+
// Reuse an active probe that already returns the app's session fields:
|
|
29
|
+
// <AppIdentityBridge probePath="/api/auth/session" onSession={seedSessionCache}>
|
|
30
|
+
// {children}
|
|
31
|
+
// </AppIdentityBridge>
|
|
32
|
+
// seedSessionCache receives unknown JSON; validate app-specific fields before caching.
|
|
33
|
+
// The callback runs before children mount, only for a successful active probe.
|
|
34
|
+
|
|
28
35
|
// app/api/auth/app-code/route.ts
|
|
29
36
|
import { createAppCodeRouteHandler } from "@hosty-sdk/app/server";
|
|
30
37
|
export const dynamic = "force-dynamic";
|
package/dist/activity-react.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
import type { AppAuthProtocol } from "./app-code.js";
|
|
1
2
|
/** Keep mounted beside app content so expiry never discards drafts or component state. */
|
|
2
|
-
export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired }: {
|
|
3
|
+
export declare function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired, appAuthProtocol, probePath, native }: {
|
|
3
4
|
openUrl: string;
|
|
4
5
|
appCodePath: string;
|
|
5
6
|
activeUntil?: string | null;
|
|
6
7
|
activityRequired?: boolean;
|
|
8
|
+
appAuthProtocol?: AppAuthProtocol | null;
|
|
9
|
+
probePath?: string;
|
|
10
|
+
native?: boolean;
|
|
7
11
|
}): import("react").JSX.Element | null;
|
package/dist/activity-react.js
CHANGED
|
@@ -3,8 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { AuthNotice, AuthNoticeButton } from "./auth-notice.js";
|
|
5
5
|
import { APP_SESSION_ENDED, APP_ACTIVITY_RENEWED, appActivityNeedsRenewal, configureAppActivity, updateAppActivity, renewAppActivity, cancelActivityRenewal } from "./browser-auth.js";
|
|
6
|
+
import { resolveLaunchMode, detectLaunchMode, normalizeLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY } from "./index.js";
|
|
7
|
+
import { acceptAppAuthProtocol, refreshAppAuthProtocol } from "./browser-auth.js";
|
|
6
8
|
/** Keep mounted beside app content so expiry never discards drafts or component state. */
|
|
7
|
-
export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired = false }) {
|
|
9
|
+
export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityRequired = false, appAuthProtocol, probePath = "/api/auth/identity", native }) {
|
|
8
10
|
const [needed, setNeeded] = useState(false);
|
|
9
11
|
const [pending, setPending] = useState(false);
|
|
10
12
|
const [error, setError] = useState("");
|
|
@@ -15,14 +17,35 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
|
|
|
15
17
|
void operation.catch(e => { setNeeded(true); setError(String(e.message ?? e)); }).finally(() => setPending(false));
|
|
16
18
|
};
|
|
17
19
|
useEffect(() => {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
let cleanup;
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
const configure = (protocol) => {
|
|
24
|
+
if (cancelled || !protocol || acceptAppAuthProtocol(new URL(openUrl).origin, protocol) !== protocol)
|
|
25
|
+
return;
|
|
26
|
+
let stored = null;
|
|
27
|
+
try {
|
|
28
|
+
stored = window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
|
|
29
|
+
}
|
|
30
|
+
catch { /* The applied document mode remains available. */ }
|
|
31
|
+
const mode = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE)) ?? resolveLaunchMode({
|
|
32
|
+
param: new URL(window.location.href).searchParams.get(LAUNCH_MODE_PARAM), stored, heuristic: detectLaunchMode(window),
|
|
33
|
+
}).mode;
|
|
34
|
+
cleanup = configureAppActivity({ openUrl, appAuthProtocol: protocol, probePath, native: native ?? mode === "native", activeUntil, activityRequired, exchangeCode: async (proof) => {
|
|
35
|
+
const response = await fetch(appCodePath, { method: "POST", credentials: "same-origin", redirect: "error",
|
|
36
|
+
headers: { "Content-Type": "application/json" }, body: JSON.stringify(proof) });
|
|
37
|
+
const body = await response.json();
|
|
38
|
+
if (!response.ok)
|
|
39
|
+
throw new Error(body.message ?? "Could not renew app activity.");
|
|
40
|
+
return body;
|
|
41
|
+
} });
|
|
42
|
+
};
|
|
43
|
+
if (appAuthProtocol !== undefined && !(appAuthProtocol === 1 && acceptAppAuthProtocol(new URL(openUrl).origin, 1) === null))
|
|
44
|
+
configure(appAuthProtocol);
|
|
45
|
+
else
|
|
46
|
+
void refreshAppAuthProtocol(openUrl, probePath, controller.signal).then(configure);
|
|
47
|
+
// A stale protocol-1 prop cannot overwrite a protocol-2 minimum already verified
|
|
48
|
+
// by this app. Unknown discovery leaves renewal disabled instead of downgrading.
|
|
26
49
|
const expired = () => setNeeded(true);
|
|
27
50
|
const renewed = () => { setNeeded(false); setError(""); };
|
|
28
51
|
const click = (event) => {
|
|
@@ -35,8 +58,8 @@ export function AppActivityBridge({ openUrl, appCodePath, activeUntil, activityR
|
|
|
35
58
|
window.addEventListener(APP_SESSION_ENDED, expired);
|
|
36
59
|
window.addEventListener(APP_ACTIVITY_RENEWED, renewed);
|
|
37
60
|
document.addEventListener("click", click, true);
|
|
38
|
-
return () => { cleanup(); window.removeEventListener(APP_SESSION_ENDED, expired); window.removeEventListener(APP_ACTIVITY_RENEWED, renewed); document.removeEventListener("click", click, true); };
|
|
39
|
-
}, [openUrl, appCodePath]);
|
|
61
|
+
return () => { cancelled = true; controller.abort(); cleanup?.(); window.removeEventListener(APP_SESSION_ENDED, expired); window.removeEventListener(APP_ACTIVITY_RENEWED, renewed); document.removeEventListener("click", click, true); };
|
|
62
|
+
}, [openUrl, appCodePath, appAuthProtocol, probePath, native]);
|
|
40
63
|
useEffect(() => { updateAppActivity(activeUntil, activityRequired); }, [activeUntil, activityRequired, openUrl, appCodePath]);
|
|
41
64
|
if (!needed)
|
|
42
65
|
return null;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Shared by framework server adapters and plain Node apps. Never sends an app credential. */
|
|
2
|
+
export type AppAuthProtocol = 1 | 2;
|
|
3
|
+
export declare function isValidCodeVerifier(value: unknown): value is string;
|
|
4
|
+
export declare function appAuthProtocolMinimum(coreOrigin: string | null): 2 | null;
|
|
5
|
+
/** A 404 alone is insufficient: only an identified, older Core permits the old navigation. */
|
|
6
|
+
export declare function resolveAppAuthProtocol(coreOrigin: string | null): Promise<AppAuthProtocol | null>;
|
package/dist/app-code.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export function isValidCodeVerifier(value) {
|
|
2
|
+
return typeof value === "string" && /^[A-Za-z0-9._~-]{43,128}$/.test(value);
|
|
3
|
+
}
|
|
4
|
+
const minimumProtocols = new Set();
|
|
5
|
+
const discoveries = new Map();
|
|
6
|
+
export function appAuthProtocolMinimum(coreOrigin) {
|
|
7
|
+
try {
|
|
8
|
+
return minimumProtocols.has(new URL(coreOrigin ?? "").origin) ? 2 : null;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function isOlderCoreVersion(value) {
|
|
15
|
+
if (typeof value !== "string")
|
|
16
|
+
return false;
|
|
17
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/.exec(value);
|
|
18
|
+
if (!match)
|
|
19
|
+
return false;
|
|
20
|
+
for (const [index, suffix] of [[4, match[4]], [5, match[5]]]) {
|
|
21
|
+
if (suffix && suffix.split(".").some(part => !/^[0-9A-Za-z-]+$/.test(part) || (index === 4 && /^0\d+$/.test(part))))
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const major = Number(match[1]), minor = Number(match[2]), patch = Number(match[3]);
|
|
25
|
+
return [major, minor, patch].every(Number.isSafeInteger) && major === 0 && minor < 120;
|
|
26
|
+
}
|
|
27
|
+
/** A 404 alone is insufficient: only an identified, older Core permits the old navigation. */
|
|
28
|
+
export async function resolveAppAuthProtocol(coreOrigin) {
|
|
29
|
+
let origin;
|
|
30
|
+
try {
|
|
31
|
+
const parsed = new URL(coreOrigin ?? "");
|
|
32
|
+
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password ||
|
|
33
|
+
parsed.pathname !== "/" || parsed.search || parsed.hash)
|
|
34
|
+
return null;
|
|
35
|
+
origin = parsed.origin;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const pending = discoveries.get(origin);
|
|
41
|
+
if (pending)
|
|
42
|
+
return pending;
|
|
43
|
+
const operation = (async () => {
|
|
44
|
+
const request = (path) => fetch(new URL(path, origin), {
|
|
45
|
+
cache: "no-store", redirect: "error", headers: { accept: "application/json" },
|
|
46
|
+
signal: AbortSignal.timeout(1_500),
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
const response = await request("/api/auth/apps/protocol");
|
|
50
|
+
if (response.status === 200) {
|
|
51
|
+
if (!response.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
|
|
52
|
+
return null;
|
|
53
|
+
const body = await response.json().catch(() => null);
|
|
54
|
+
if (body?.version !== 2)
|
|
55
|
+
return null;
|
|
56
|
+
minimumProtocols.add(origin);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
if (response.status !== 404 || minimumProtocols.has(origin))
|
|
60
|
+
return null;
|
|
61
|
+
const status = await request("/api/core/status");
|
|
62
|
+
if (status.status !== 200)
|
|
63
|
+
return null;
|
|
64
|
+
if (!status.headers.get("content-type")?.toLowerCase().startsWith("application/json"))
|
|
65
|
+
return null;
|
|
66
|
+
const body = await status.json().catch(() => null);
|
|
67
|
+
return body?.component === "hosty-core" && body.status === "running" && isOlderCoreVersion(body.version) ? 1 : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
discoveries.set(origin, operation);
|
|
74
|
+
try {
|
|
75
|
+
return await operation;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
discoveries.delete(origin);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/dist/browser-auth.d.ts
CHANGED
|
@@ -1,4 +1,33 @@
|
|
|
1
|
+
import type { AppAuthProtocol } from "./app-code.js";
|
|
2
|
+
export type AppSignInResult = {
|
|
3
|
+
code: string;
|
|
4
|
+
codeVerifier: string;
|
|
5
|
+
};
|
|
6
|
+
export type AppAuthAttempt = {
|
|
7
|
+
state: string;
|
|
8
|
+
codeVerifier: string;
|
|
9
|
+
codeChallenge: string;
|
|
10
|
+
appId: string;
|
|
11
|
+
coreOrigin: string;
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
mode: "standalone" | "silent" | "popup" | "native";
|
|
15
|
+
protocol: AppAuthProtocol;
|
|
16
|
+
};
|
|
17
|
+
export declare const APP_AUTH_ATTEMPT_PREFIX = "hosty.auth.attempt:";
|
|
18
|
+
export declare function appCodeChallenge(verifier: string): string;
|
|
19
|
+
export declare function acceptAppAuthProtocol(coreOrigin: string, protocol: AppAuthProtocol | null): AppAuthProtocol | null;
|
|
20
|
+
export declare function createAppAuthAttempt(openUrl: string, mode: AppAuthAttempt["mode"], protocol: AppAuthProtocol): AppAuthAttempt;
|
|
21
|
+
/** Do not leave the app document unless this exact proof survives a storage round trip. */
|
|
22
|
+
export declare function persistAppAuthAttempt(attempt: AppAuthAttempt): boolean;
|
|
23
|
+
/** Correlation is local; URL fields never supply a verifier or a pending attempt. */
|
|
24
|
+
export declare function takeAppAuthAttempt(callback: URL, allowLegacyUpgrade?: boolean): AppAuthAttempt | null;
|
|
25
|
+
export declare function clearAppAuthAttempts(): void;
|
|
26
|
+
export declare function appAuthNavigationUrl(attempt: AppAuthAttempt): string;
|
|
27
|
+
/** Constant document and DOM-built fields prevent a public URL from laundering a challenge. */
|
|
28
|
+
export declare function submitAppAuthIntent(attempt: AppAuthAttempt, doc?: Document): void;
|
|
1
29
|
export declare const APP_SESSION_ENDED = "hosty:app-session-ended";
|
|
30
|
+
export declare const NATIVE_APP_AUTH_RESULT = "hosty:native-auth-result";
|
|
2
31
|
export declare const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
|
|
3
32
|
/** Restore before the first identity probe; standalone documents use their first-party cookie. */
|
|
4
33
|
export declare function restoreAppGrant(): boolean;
|
|
@@ -11,14 +40,19 @@ export declare function forgetRejectedAppGrant(code: unknown, expectedRevision?:
|
|
|
11
40
|
export declare function appSessionActive(): void;
|
|
12
41
|
/** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
|
|
13
42
|
export declare function appFetch(input: string | URL, init?: RequestInit, recover?: boolean): Promise<Response>;
|
|
14
|
-
/**
|
|
15
|
-
export declare function
|
|
43
|
+
/** Refresh only the app-local, server-verified metadata for this frozen app/Core pair. */
|
|
44
|
+
export declare function refreshAppAuthProtocol(openUrl: string, probePath?: string, signal?: AbortSignal): Promise<AppAuthProtocol | null>;
|
|
45
|
+
/** Must be called directly from a user gesture. Native hosts intercept before any request. */
|
|
46
|
+
export declare function openAppSignIn(openUrl: string, signal?: AbortSignal, appAuthProtocol?: AppAuthProtocol, native?: boolean, probePath?: string): Promise<AppSignInResult>;
|
|
16
47
|
export declare const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
|
|
17
48
|
export type AppActivityConfig = {
|
|
18
49
|
openUrl: string;
|
|
50
|
+
appAuthProtocol: AppAuthProtocol;
|
|
51
|
+
native?: boolean;
|
|
52
|
+
probePath?: string;
|
|
19
53
|
activeUntil?: string | null;
|
|
20
54
|
activityRequired?: boolean;
|
|
21
|
-
exchangeCode: (
|
|
55
|
+
exchangeCode: (result: AppSignInResult) => Promise<{
|
|
22
56
|
accessToken?: string;
|
|
23
57
|
activeUntil?: string | null;
|
|
24
58
|
}>;
|
package/dist/browser-auth.js
CHANGED
|
@@ -1,8 +1,190 @@
|
|
|
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
|
+
}
|
|
1
181
|
/** App-only credentials. Embedded documents keep them for this tab on the app's own origin. */
|
|
2
182
|
let grant = null;
|
|
3
183
|
let grantRevision = 0;
|
|
4
184
|
let recoveryPending = false;
|
|
185
|
+
const popupCancellations = new Set();
|
|
5
186
|
export const APP_SESSION_ENDED = "hosty:app-session-ended";
|
|
187
|
+
export const NATIVE_APP_AUTH_RESULT = "hosty:native-auth-result";
|
|
6
188
|
export const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
|
|
7
189
|
function isEmbedded() {
|
|
8
190
|
try {
|
|
@@ -44,6 +226,15 @@ export function rememberAppGrant(token) {
|
|
|
44
226
|
}
|
|
45
227
|
}
|
|
46
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() {
|
|
47
238
|
if (grant)
|
|
48
239
|
grantRevision++;
|
|
49
240
|
grant = null;
|
|
@@ -63,7 +254,7 @@ export function forgetRejectedAppGrant(code, expectedRevision = grantRevision) {
|
|
|
63
254
|
return false;
|
|
64
255
|
if (typeof code !== "string" || !["token_invalid", "token_revoked", "token_expired", "token_app_mismatch"].includes(code))
|
|
65
256
|
return false;
|
|
66
|
-
|
|
257
|
+
discardAppGrant();
|
|
67
258
|
return true;
|
|
68
259
|
}
|
|
69
260
|
export function appSessionActive() { recoveryPending = false; }
|
|
@@ -90,7 +281,7 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
90
281
|
if (response.status !== 401)
|
|
91
282
|
return response;
|
|
92
283
|
if (!rejected && recover && body?.code !== "reauth_required" && current)
|
|
93
|
-
|
|
284
|
+
discardAppGrant();
|
|
94
285
|
if (!recover)
|
|
95
286
|
return response;
|
|
96
287
|
if (activityConfig) {
|
|
@@ -110,37 +301,147 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
110
301
|
}
|
|
111
302
|
return response;
|
|
112
303
|
}
|
|
113
|
-
/**
|
|
114
|
-
export function
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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);
|
|
121
350
|
return new Promise((resolve, reject) => {
|
|
351
|
+
let attempt = initialAttempt;
|
|
352
|
+
initialAttempt = null;
|
|
122
353
|
let popup = null;
|
|
123
354
|
let settled = false;
|
|
355
|
+
let upgraded = false;
|
|
356
|
+
let restartPoll;
|
|
357
|
+
const refreshController = new AbortController();
|
|
124
358
|
const finish = (code, error) => {
|
|
125
359
|
if (settled)
|
|
126
360
|
return;
|
|
127
361
|
settled = true;
|
|
362
|
+
const proof = code && attempt ? { code, codeVerifier: attempt.codeVerifier } : null;
|
|
363
|
+
attempt = null;
|
|
364
|
+
refreshController.abort();
|
|
128
365
|
window.removeEventListener("message", receive);
|
|
129
366
|
signal?.removeEventListener("abort", abort);
|
|
367
|
+
popupCancellations.delete(abort);
|
|
130
368
|
window.clearTimeout(timer);
|
|
131
369
|
clearInterval(closedCheck);
|
|
370
|
+
clearInterval(restartPoll);
|
|
132
371
|
popup?.close();
|
|
133
|
-
if (
|
|
134
|
-
resolve(
|
|
372
|
+
if (proof)
|
|
373
|
+
resolve(proof);
|
|
135
374
|
else
|
|
136
375
|
reject(new Error(error ?? "Sign-in was cancelled."));
|
|
137
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
|
+
};
|
|
138
428
|
const receive = (event) => {
|
|
139
|
-
if (!popup || event.source !== popup || event.origin !==
|
|
429
|
+
if (!popup || !attempt || event.source !== popup || event.origin !== attempt.coreOrigin)
|
|
140
430
|
return;
|
|
141
431
|
const data = event.data;
|
|
142
|
-
if (data?.type !== "hosty:app-auth-code" || data.state !== state
|
|
143
|
-
|
|
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)
|
|
144
445
|
return;
|
|
145
446
|
finish(data.code);
|
|
146
447
|
};
|
|
@@ -149,19 +450,80 @@ export function openAppSignIn(openUrl, signal) {
|
|
|
149
450
|
const closedCheck = setInterval(() => { if (popup?.closed)
|
|
150
451
|
finish(); }, 500);
|
|
151
452
|
window.addEventListener("message", receive);
|
|
453
|
+
popupCancellations.add(abort);
|
|
152
454
|
signal?.addEventListener("abort", abort, { once: true });
|
|
153
455
|
if (signal?.aborted) {
|
|
154
456
|
finish();
|
|
155
457
|
return;
|
|
156
458
|
}
|
|
157
|
-
|
|
158
|
-
|
|
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) {
|
|
159
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
|
+
}
|
|
160
521
|
});
|
|
161
522
|
}
|
|
162
523
|
export const APP_ACTIVITY_RENEWED = "hosty:app-activity-renewed";
|
|
163
524
|
let activityConfig = null;
|
|
164
525
|
let renewal = null;
|
|
526
|
+
let renewalController = null;
|
|
165
527
|
const waiting = new Set();
|
|
166
528
|
export function configureAppActivity(config) {
|
|
167
529
|
activityConfig = config;
|
|
@@ -176,7 +538,7 @@ export function updateAppActivity(activeUntil, activityRequired) {
|
|
|
176
538
|
activityConfig.activityRequired = activityRequired;
|
|
177
539
|
}
|
|
178
540
|
}
|
|
179
|
-
export function cancelActivityRenewal() { for (const done of [...waiting])
|
|
541
|
+
export function cancelActivityRenewal() { renewalController?.abort(); for (const done of [...waiting])
|
|
180
542
|
done(false); }
|
|
181
543
|
function waitForActivity(signal) {
|
|
182
544
|
return new Promise(resolve => {
|
|
@@ -196,9 +558,16 @@ export function renewAppActivity() {
|
|
|
196
558
|
const config = activityConfig;
|
|
197
559
|
if (!config)
|
|
198
560
|
return Promise.reject(new Error("App recovery is not configured."));
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
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.");
|
|
202
571
|
if (result.accessToken)
|
|
203
572
|
rememberAppGrant(result.accessToken);
|
|
204
573
|
config.activeUntil = result.activeUntil;
|
|
@@ -206,7 +575,7 @@ export function renewAppActivity() {
|
|
|
206
575
|
for (const done of [...waiting])
|
|
207
576
|
done(true);
|
|
208
577
|
window.dispatchEvent(new Event(APP_ACTIVITY_RENEWED));
|
|
209
|
-
}).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; });
|
|
578
|
+
}).catch(error => { cancelActivityRenewal(); throw error; }).finally(() => { renewal = null; renewalController = null; });
|
|
210
579
|
return renewal;
|
|
211
580
|
}
|
|
212
581
|
export function appActivityNeedsRenewal(now = Date.now()) {
|
package/dist/index.d.ts
CHANGED
|
@@ -102,6 +102,7 @@ export declare function classifyRevalidationHttpStatus(status: number): AppSessi
|
|
|
102
102
|
export interface SessionRecoveryParams {
|
|
103
103
|
appId: string | null;
|
|
104
104
|
corePublicOrigin: string | null;
|
|
105
|
+
appAuthProtocol: 1 | 2 | null;
|
|
105
106
|
}
|
|
106
107
|
export declare function readRecoveryParams(body: unknown): SessionRecoveryParams;
|
|
107
108
|
/** True for hosts that only resolve on the machine itself. */
|
package/dist/index.js
CHANGED
|
@@ -89,10 +89,11 @@ export function classifyRevalidationHttpStatus(status) {
|
|
|
89
89
|
export function readRecoveryParams(body) {
|
|
90
90
|
const recovery = body && typeof body === "object" ? body.recovery : null;
|
|
91
91
|
if (!recovery || typeof recovery !== "object") {
|
|
92
|
-
return { appId: null, corePublicOrigin: null };
|
|
92
|
+
return { appId: null, corePublicOrigin: null, appAuthProtocol: null };
|
|
93
93
|
}
|
|
94
|
-
const { appId, corePublicOrigin } = recovery;
|
|
94
|
+
const { appId, corePublicOrigin, appAuthProtocol } = recovery;
|
|
95
95
|
return {
|
|
96
|
+
appAuthProtocol: appAuthProtocol === 1 || appAuthProtocol === 2 ? appAuthProtocol : null,
|
|
96
97
|
appId: typeof appId === "string" && appId.length > 0 ? appId : null,
|
|
97
98
|
corePublicOrigin: typeof corePublicOrigin === "string" && corePublicOrigin.length > 0
|
|
98
99
|
? corePublicOrigin
|
|
@@ -102,7 +103,7 @@ export function readRecoveryParams(body) {
|
|
|
102
103
|
/** True for hosts that only resolve on the machine itself. */
|
|
103
104
|
export function isLoopbackHost(hostname) {
|
|
104
105
|
const host = hostname.toLowerCase();
|
|
105
|
-
return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
106
|
+
return host === "localhost" || host.endsWith(".localhost") || host === "127.0.0.1" || host === "::1" || host === "[::1]";
|
|
106
107
|
}
|
|
107
108
|
/**
|
|
108
109
|
* Builds the Core standalone re-auth URL (`/api/apps/{id}/open?redirectUri=…`) for the
|
package/dist/install.d.ts
CHANGED
|
@@ -83,7 +83,7 @@ export interface InstallationSource {
|
|
|
83
83
|
description?: string | null;
|
|
84
84
|
};
|
|
85
85
|
};
|
|
86
|
-
/** Personal source selection requires apps.
|
|
86
|
+
/** Personal source selection requires apps.install plus apps.sources or sources.connections and Core review. */
|
|
87
87
|
sourceConnections?: {
|
|
88
88
|
manifestConnectionId?: string;
|
|
89
89
|
gitConnectionId?: string;
|
package/dist/react.d.ts
CHANGED
|
@@ -50,6 +50,8 @@ export interface AppIdentityBridgeProps {
|
|
|
50
50
|
appCodePath?: string;
|
|
51
51
|
/** Optional app-owned rendering, including loading and active content. Recovery remains SDK-owned. */
|
|
52
52
|
renderState?: (state: AppIdentityBridgeState) => ReactNode;
|
|
53
|
+
/** Receives a successful active probe before children mount, so an app can seed its session cache. */
|
|
54
|
+
onSession?: (body: unknown) => void;
|
|
53
55
|
children?: ReactNode;
|
|
54
56
|
}
|
|
55
57
|
/**
|
|
@@ -60,7 +62,7 @@ export interface AppIdentityBridgeProps {
|
|
|
60
62
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
61
63
|
* — never a login UI while recovery is still running.
|
|
62
64
|
*/
|
|
63
|
-
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, children, }?: AppIdentityBridgeProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
65
|
+
export declare function AppIdentityBridge({ probePath, appCodePath, renderState, onSession, children, }?: AppIdentityBridgeProps): string | number | bigint | boolean | Iterable<ReactNode> | Promise<string | number | bigint | boolean | import("react").ReactPortal | import("react").ReactElement<unknown, string | import("react").JSXElementConstructor<any>> | Iterable<ReactNode> | null | undefined> | import("react").JSX.Element | null | undefined;
|
|
64
66
|
export interface HostThemeBridgeProps {
|
|
65
67
|
/**
|
|
66
68
|
* Whether the operating system decides the theme while no shell has spoken — `true` unless the
|
package/dist/react.js
CHANGED
|
@@ -7,12 +7,12 @@ import { AppActivityBridge } from "./activity-react.js";
|
|
|
7
7
|
import { AuthNotice, AuthNoticeButton, authNoticeActionStyle } from "./auth-notice.js";
|
|
8
8
|
import { useEffect, useRef, useState } from "react";
|
|
9
9
|
import { appFetch, appSessionActive, appGrantRevision, rememberAppGrant, restoreAppGrant, forgetRejectedAppGrant, openAppSignIn, APP_SESSION_ENDED } from "./browser-auth.js";
|
|
10
|
+
import { acceptAppAuthProtocol, createAppAuthAttempt, persistAppAuthAttempt, takeAppAuthAttempt, submitAppAuthIntent, appAuthNavigationUrl, APP_AUTH_ATTEMPT_PREFIX } from "./browser-auth.js";
|
|
10
11
|
import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
|
|
11
12
|
import { applyTheme, parseShellThemeMessage, resolveTheme, THEME_PARAM, THEME_PREFERENCE_PARAM, THEME_PREFERENCE_STORAGE_KEY, THEME_STORAGE_KEY, } from "./theme.js";
|
|
12
13
|
// Once-per-tab guard so a standalone app that returns from Core still unauthorized does
|
|
13
14
|
// not bounce through /open forever. Cleared on a successful code exchange.
|
|
14
15
|
const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
|
|
15
|
-
const SILENT_STATE_KEY = "hosty.auth.silent-state";
|
|
16
16
|
const SILENT_GUARD_PREFIX = "hosty.auth.silent-attempted:";
|
|
17
17
|
// Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
|
|
18
18
|
// timeout it classifies as unavailable and the user gets a Retry affordance.
|
|
@@ -69,36 +69,38 @@ function writeGuard(value) {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
/** Both state and the per-app guard must survive navigation, or the popup is the fallback. */
|
|
72
|
-
function
|
|
72
|
+
function beginNavigationSignIn(openUrl, appId, protocol, mode, force = false) {
|
|
73
|
+
let state = null;
|
|
73
74
|
try {
|
|
74
|
-
const guardKey = `${SILENT_GUARD_PREFIX}${appId}
|
|
75
|
-
if (window.sessionStorage.getItem(guardKey) === "1")
|
|
76
|
-
return
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (window.sessionStorage.getItem(SILENT_STATE_KEY) !== state || window.sessionStorage.getItem(guardKey) !== "1")
|
|
81
|
-
return null;
|
|
82
|
-
const url = new URL(openUrl);
|
|
83
|
-
url.searchParams.set("prompt", "none");
|
|
84
|
-
url.searchParams.set("state", state);
|
|
85
|
-
return url.href;
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return null;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
function consumeSilentState(state) {
|
|
92
|
-
if (!state)
|
|
93
|
-
return false;
|
|
94
|
-
try {
|
|
95
|
-
const expected = window.sessionStorage.getItem(SILENT_STATE_KEY);
|
|
96
|
-
if (!expected || expected !== state || !/^[a-f0-9]{64}$/.test(state))
|
|
75
|
+
const guardKey = mode === "silent" ? `${SILENT_GUARD_PREFIX}${appId}` : RECOVERY_GUARD_KEY;
|
|
76
|
+
if (!force && window.sessionStorage.getItem(guardKey) === "1")
|
|
77
|
+
return false;
|
|
78
|
+
const attempt = createAppAuthAttempt(openUrl, mode, protocol);
|
|
79
|
+
state = attempt.state;
|
|
80
|
+
if (!persistAppAuthAttempt(attempt))
|
|
97
81
|
return false;
|
|
98
|
-
window.sessionStorage.
|
|
82
|
+
window.sessionStorage.setItem(guardKey, "1");
|
|
83
|
+
if (window.sessionStorage.getItem(guardKey) !== "1")
|
|
84
|
+
throw new Error("Sign-in storage is unavailable.");
|
|
85
|
+
if (mode === "native")
|
|
86
|
+
window.location.assign(appAuthNavigationUrl(attempt));
|
|
87
|
+
else if (protocol === 1) {
|
|
88
|
+
if (mode === "silent")
|
|
89
|
+
window.location.replace(appAuthNavigationUrl(attempt));
|
|
90
|
+
else
|
|
91
|
+
window.location.assign(appAuthNavigationUrl(attempt));
|
|
92
|
+
}
|
|
93
|
+
else
|
|
94
|
+
submitAppAuthIntent(attempt);
|
|
99
95
|
return true;
|
|
100
96
|
}
|
|
101
97
|
catch {
|
|
98
|
+
if (state) {
|
|
99
|
+
try {
|
|
100
|
+
window.sessionStorage.removeItem(`${APP_AUTH_ATTEMPT_PREFIX}${state}`);
|
|
101
|
+
}
|
|
102
|
+
catch { /* Popup fallback. */ }
|
|
103
|
+
}
|
|
102
104
|
return false;
|
|
103
105
|
}
|
|
104
106
|
}
|
|
@@ -112,7 +114,7 @@ function readStoredLaunchMode() {
|
|
|
112
114
|
}
|
|
113
115
|
/** The mode already applied to the document, or a fresh resolution when nothing applied one. */
|
|
114
116
|
function currentLaunchMode() {
|
|
115
|
-
const applied = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
|
|
117
|
+
const applied = typeof document === "undefined" ? null : normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
|
|
116
118
|
if (applied) {
|
|
117
119
|
return applied;
|
|
118
120
|
}
|
|
@@ -185,7 +187,7 @@ export function useLaunchMode() {
|
|
|
185
187
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
186
188
|
* — never a login UI while recovery is still running.
|
|
187
189
|
*/
|
|
188
|
-
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, children, } = {}) {
|
|
190
|
+
export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePath = "/api/auth/app-code", renderState, onSession, children, } = {}) {
|
|
189
191
|
const [ui, setUi] = useState({ kind: "recovering" });
|
|
190
192
|
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
191
193
|
// keep its exchange alive and let the replacement effect await the same response.
|
|
@@ -193,8 +195,12 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
193
195
|
const firstProbe = useRef(true);
|
|
194
196
|
const initialGrantPresent = useRef(null);
|
|
195
197
|
const silentError = useRef(null);
|
|
198
|
+
const returnedLegacyAttempt = useRef(null);
|
|
199
|
+
const upgradedAttempt = useRef(false);
|
|
196
200
|
const [activity, setActivity] = useState(null);
|
|
197
201
|
const exchangeRef = useRef(null);
|
|
202
|
+
const onSessionRef = useRef(onSession);
|
|
203
|
+
useEffect(() => { onSessionRef.current = onSession; }, [onSession]);
|
|
198
204
|
useEffect(() => {
|
|
199
205
|
let cancelled = false;
|
|
200
206
|
const controller = new AbortController();
|
|
@@ -211,7 +217,10 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
211
217
|
let status = null;
|
|
212
218
|
let openUrl = null;
|
|
213
219
|
let appId = null;
|
|
220
|
+
let protocol = null;
|
|
221
|
+
let activityMetadata = null;
|
|
214
222
|
let rejectionCode;
|
|
223
|
+
let sessionBody = null;
|
|
215
224
|
const revision = appGrantRevision();
|
|
216
225
|
try {
|
|
217
226
|
const response = await appFetch(probePath, {
|
|
@@ -221,14 +230,16 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
221
230
|
}, false);
|
|
222
231
|
const body = await response.json().catch(() => null);
|
|
223
232
|
status = readProbedSessionStatus(body);
|
|
233
|
+
if (status === "active" && !response.ok)
|
|
234
|
+
status = null;
|
|
235
|
+
sessionBody = body;
|
|
224
236
|
const identityError = body;
|
|
225
237
|
rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
|
|
226
238
|
const recovery = readRecoveryParams(body);
|
|
227
239
|
appId = recovery.appId;
|
|
228
240
|
openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
setActivity({ openUrl, activeUntil: metadata?.activeUntil, activityRequired: metadata?.activityRequired });
|
|
241
|
+
protocol = openUrl ? acceptAppAuthProtocol(new URL(openUrl).origin, recovery.appAuthProtocol) : recovery.appAuthProtocol;
|
|
242
|
+
activityMetadata = body;
|
|
232
243
|
}
|
|
233
244
|
catch {
|
|
234
245
|
// A failed or timed-out probe (Core unreachable) classifies as unavailable below.
|
|
@@ -247,71 +258,93 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
247
258
|
return;
|
|
248
259
|
}
|
|
249
260
|
forgetRejectedAppGrant(rejectionCode, revision);
|
|
261
|
+
if (openUrl && protocol)
|
|
262
|
+
setActivity({ openUrl, appAuthProtocol: protocol,
|
|
263
|
+
activeUntil: activityMetadata?.activeUntil, activityRequired: activityMetadata?.activityRequired });
|
|
250
264
|
const initialLoad = firstProbe.current;
|
|
251
265
|
firstProbe.current = false;
|
|
252
266
|
if (silentError.current === "access_denied") {
|
|
253
267
|
setUi({ kind: "denied" });
|
|
254
268
|
return;
|
|
255
269
|
}
|
|
256
|
-
|
|
270
|
+
const mode = currentLaunchMode();
|
|
271
|
+
const legacy = returnedLegacyAttempt.current;
|
|
272
|
+
const sameLegacyCore = !!legacy && appId === legacy.appId && !!openUrl && new URL(openUrl).origin === legacy.coreOrigin;
|
|
273
|
+
if (legacy?.protocolRequired && (status === "not-present" || status === "expired") && (!sameLegacyCore || protocol !== 2)) {
|
|
274
|
+
setUi({ kind: "unavailable" });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const shouldUpgrade = sameLegacyCore && protocol === 2 && !upgradedAttempt.current;
|
|
278
|
+
if (shouldUpgrade) {
|
|
279
|
+
upgradedAttempt.current = true;
|
|
280
|
+
returnedLegacyAttempt.current = null;
|
|
281
|
+
}
|
|
282
|
+
if (!wasActive.current && protocol && ((initialLoad && !initialGrantPresent.current && !silentError.current) || shouldUpgrade) &&
|
|
257
283
|
detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
|
|
258
|
-
|
|
259
|
-
if (silentUrl) {
|
|
260
|
-
window.location.replace(silentUrl);
|
|
284
|
+
if (beginNavigationSignIn(openUrl, appId, protocol, "silent", shouldUpgrade))
|
|
261
285
|
return;
|
|
262
|
-
}
|
|
263
286
|
}
|
|
264
|
-
const action = !status || !appId
|
|
287
|
+
const action = !status || !appId || ((status === "not-present" || status === "expired") && !protocol)
|
|
265
288
|
? { kind: "card", card: "unavailable" }
|
|
266
289
|
: decideRecoveryAction({
|
|
267
290
|
status: silentError.current === "login_required" ? "not-present" : status,
|
|
268
|
-
mode
|
|
291
|
+
mode,
|
|
269
292
|
appId,
|
|
270
293
|
openUrl,
|
|
271
|
-
redirectAlreadyAttempted: readGuard(),
|
|
294
|
+
redirectAlreadyAttempted: !shouldUpgrade && readGuard(),
|
|
272
295
|
});
|
|
273
296
|
switch (action.kind) {
|
|
274
297
|
case "none":
|
|
298
|
+
onSessionRef.current?.(sessionBody);
|
|
275
299
|
appSessionActive();
|
|
276
300
|
wasActive.current = true;
|
|
277
301
|
setUi({ kind: "active" });
|
|
278
302
|
return;
|
|
279
303
|
case "post-auth-required": {
|
|
280
|
-
const showSignIn = (error) => setUi({ kind: "signin", openUrl, embedded: true, error,
|
|
281
|
-
signIn: openUrl ? () => {
|
|
282
|
-
// Open synchronously in the click handler; an awaited request loses the user gesture.
|
|
283
|
-
const result = openAppSignIn(openUrl, controller.signal);
|
|
284
|
-
setUi({ kind: "recovering" });
|
|
285
|
-
void result.then(async (code) => {
|
|
286
|
-
const response = await appFetch(appCodePath, {
|
|
287
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
288
|
-
body: JSON.stringify({ code }),
|
|
289
|
-
});
|
|
290
|
-
const body = await response.json();
|
|
291
|
-
if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
|
|
292
|
-
throw new Error("Hosty could not establish this app session.");
|
|
293
|
-
if (cancelled)
|
|
294
|
-
return;
|
|
295
|
-
rememberAppGrant(body.accessToken);
|
|
296
|
-
silentError.current = null;
|
|
297
|
-
writeGuard(false);
|
|
298
|
-
await probeAndRecover();
|
|
299
|
-
}).catch(error => { if (!cancelled)
|
|
300
|
-
showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
|
|
301
|
-
} : undefined });
|
|
302
304
|
showSignIn();
|
|
303
305
|
return;
|
|
304
306
|
}
|
|
305
307
|
case "redirect":
|
|
306
|
-
|
|
307
|
-
|
|
308
|
+
if (appId && protocol && beginNavigationSignIn(action.openUrl, appId, protocol, mode === "native" ? "native" : "standalone", shouldUpgrade))
|
|
309
|
+
return;
|
|
310
|
+
showSignIn("Allow app storage, or sign in with a popup.");
|
|
308
311
|
return;
|
|
309
312
|
case "card":
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
+
if (action.card === "signin")
|
|
314
|
+
showSignIn();
|
|
315
|
+
else
|
|
316
|
+
setUi({ kind: action.card });
|
|
313
317
|
return;
|
|
314
318
|
}
|
|
319
|
+
function showSignIn(error) {
|
|
320
|
+
setUi({ kind: "signin", openUrl, embedded: mode === "embedded", error,
|
|
321
|
+
signIn: openUrl && protocol ? () => {
|
|
322
|
+
// Open synchronously in the click handler; an awaited request loses the user gesture.
|
|
323
|
+
const revision = appGrantRevision();
|
|
324
|
+
const result = openAppSignIn(openUrl, controller.signal, protocol, mode === "native", probePath);
|
|
325
|
+
setUi({ kind: "recovering" });
|
|
326
|
+
void result.then(async (proof) => {
|
|
327
|
+
const response = await appFetch(appCodePath, {
|
|
328
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
329
|
+
body: JSON.stringify(proof),
|
|
330
|
+
}, false);
|
|
331
|
+
const body = await response.json();
|
|
332
|
+
if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
|
|
333
|
+
throw new Error("Hosty could not establish this app session.");
|
|
334
|
+
if (cancelled)
|
|
335
|
+
return;
|
|
336
|
+
if (revision !== appGrantRevision()) {
|
|
337
|
+
await probeAndRecover();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
rememberAppGrant(body.accessToken);
|
|
341
|
+
silentError.current = null;
|
|
342
|
+
writeGuard(false);
|
|
343
|
+
await probeAndRecover();
|
|
344
|
+
}).catch(error => { if (!cancelled)
|
|
345
|
+
showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
|
|
346
|
+
} : undefined });
|
|
347
|
+
}
|
|
315
348
|
}
|
|
316
349
|
const recover = () => { if (!cancelled && !wasActive.current) {
|
|
317
350
|
setUi({ kind: "recovering" });
|
|
@@ -321,27 +354,30 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
321
354
|
const url = new URL(window.location.href);
|
|
322
355
|
const code = url.searchParams.get("code")?.trim();
|
|
323
356
|
const error = url.searchParams.get("error");
|
|
324
|
-
const embedded = detectLaunchMode(window) === "embedded";
|
|
325
357
|
const isSilentError = error === "login_required" || error === "access_denied";
|
|
326
|
-
const
|
|
327
|
-
|
|
358
|
+
const attempt = takeAppAuthAttempt(url, true);
|
|
359
|
+
const upgradedLegacy = attempt?.protocol === 1 && acceptAppAuthProtocol(attempt.coreOrigin, 1) === null;
|
|
360
|
+
if (attempt?.mode === "silent" && isSilentError)
|
|
328
361
|
silentError.current = error;
|
|
329
|
-
|
|
362
|
+
const protocolRequired = attempt?.protocol === 1 && !code && error === "protocol_required" && url.searchParams.getAll("error").length === 1;
|
|
363
|
+
if (attempt?.protocol === 1 && (!error || protocolRequired || upgradedLegacy))
|
|
364
|
+
returnedLegacyAttempt.current = { appId: attempt.appId, coreOrigin: attempt.coreOrigin, protocolRequired: protocolRequired || upgradedLegacy };
|
|
365
|
+
if (code || isSilentError || attempt) {
|
|
330
366
|
url.searchParams.delete("code");
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
url.searchParams.delete("error");
|
|
335
|
-
}
|
|
367
|
+
url.searchParams.delete("state");
|
|
368
|
+
if (isSilentError || protocolRequired)
|
|
369
|
+
url.searchParams.delete("error");
|
|
336
370
|
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
337
371
|
}
|
|
338
|
-
if (code &&
|
|
372
|
+
if (code && !error && !upgradedLegacy && attempt && !exchangeRef.current) {
|
|
339
373
|
exchangeRef.current = {
|
|
340
374
|
path: appCodePath,
|
|
375
|
+
revision: appGrantRevision(),
|
|
341
376
|
response: fetch(appCodePath, {
|
|
342
377
|
method: "POST",
|
|
343
378
|
headers: { "Content-Type": "application/json" },
|
|
344
|
-
body: JSON.stringify({ code }),
|
|
379
|
+
body: JSON.stringify({ code, codeVerifier: attempt.codeVerifier }),
|
|
380
|
+
credentials: "same-origin", redirect: "error",
|
|
345
381
|
}),
|
|
346
382
|
};
|
|
347
383
|
}
|
|
@@ -355,6 +391,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
355
391
|
if (response.ok) {
|
|
356
392
|
writeGuard(false);
|
|
357
393
|
const body = await response.json().catch(() => null);
|
|
394
|
+
if (cancelled)
|
|
395
|
+
return;
|
|
396
|
+
if (exchange.revision !== appGrantRevision()) {
|
|
397
|
+
exchangeRef.current = null;
|
|
398
|
+
await probeAndRecover();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
358
401
|
if (typeof body?.accessToken === "string")
|
|
359
402
|
rememberAppGrant(body.accessToken);
|
|
360
403
|
exchangeRef.current = null;
|
|
@@ -382,7 +425,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
382
425
|
};
|
|
383
426
|
}, [probePath, appCodePath]);
|
|
384
427
|
if (wasActive.current)
|
|
385
|
-
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
428
|
+
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath, probePath: probePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
386
429
|
if (renderState)
|
|
387
430
|
return renderState(ui);
|
|
388
431
|
if (ui.kind === "active")
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import "server-only";
|
|
2
|
+
import { type AppAuthProtocol } from "./app-code.js";
|
|
3
|
+
export { isValidCodeVerifier } from "./app-code.js";
|
|
2
4
|
import { type AppSessionFailureStatus, type AppSessionStatus, type SessionRecoveryParams } from "./index.js";
|
|
3
5
|
/** Per-app parameters: everything that legitimately diverges between apps stays a
|
|
4
6
|
* parameter — cookie namespaces and role models are per-app by decision, never unified. */
|
|
@@ -46,7 +48,8 @@ export declare function getCoreOrigin(): string | null;
|
|
|
46
48
|
* listen URL on localhost-only installs), so a missing value is a broken environment. */
|
|
47
49
|
export declare function getCorePublicOrigin(): string | null;
|
|
48
50
|
export declare function getServiceToken(): string | null;
|
|
49
|
-
export declare function
|
|
51
|
+
export declare function getAppAuthProtocol(): Promise<AppAuthProtocol | null>;
|
|
52
|
+
export declare function getRecoveryParams(config: HostyAppConfig): Promise<SessionRecoveryParams>;
|
|
50
53
|
export type HeaderReader = {
|
|
51
54
|
get(name: string): string | null;
|
|
52
55
|
};
|
|
@@ -98,9 +101,8 @@ export type AppCodeExchange = {
|
|
|
98
101
|
code: string;
|
|
99
102
|
message: string;
|
|
100
103
|
};
|
|
101
|
-
/** Exchanges a one-time
|
|
102
|
-
|
|
103
|
-
export declare function exchangeAppCode(code: string): Promise<AppCodeExchange>;
|
|
104
|
+
/** Exchanges a one-time Core code using this app's service credential. */
|
|
105
|
+
export declare function exchangeAppCode(code: string, codeVerifier: string): Promise<AppCodeExchange>;
|
|
104
106
|
/**
|
|
105
107
|
* Route-handler factory for `POST /api/auth/app-code`: exchanges the one-time `code` for
|
|
106
108
|
* an identity token, stores it in the app-origin HttpOnly cookie, and returns the token so
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Server slice: Next route-handler factories and Core revalidation. Never reaches a client
|
|
2
2
|
// bundle — the service token lives here.
|
|
3
3
|
import "server-only";
|
|
4
|
+
import { resolveAppAuthProtocol, appAuthProtocolMinimum, isValidCodeVerifier } from "./app-code.js";
|
|
5
|
+
export { isValidCodeVerifier } from "./app-code.js";
|
|
4
6
|
import { classifyRevalidationHttpStatus, } from "./index.js";
|
|
5
7
|
// Core runs on the same host as the app services, so a short budget is plenty; a stalled
|
|
6
8
|
// call must not hold a route open indefinitely.
|
|
@@ -27,8 +29,11 @@ export function getCorePublicOrigin() {
|
|
|
27
29
|
export function getServiceToken() {
|
|
28
30
|
return process.env.HOSTY_APP_SERVICE_TOKEN?.trim() || null;
|
|
29
31
|
}
|
|
30
|
-
export function
|
|
31
|
-
return
|
|
32
|
+
export function getAppAuthProtocol() {
|
|
33
|
+
return resolveAppAuthProtocol(getCoreOrigin());
|
|
34
|
+
}
|
|
35
|
+
export async function getRecoveryParams(config) {
|
|
36
|
+
return { appId: getAppId(config), corePublicOrigin: getCorePublicOrigin(), appAuthProtocol: await getAppAuthProtocol() };
|
|
32
37
|
}
|
|
33
38
|
/**
|
|
34
39
|
* Reads an explicitly presented app grant first, then the app cookie and legacy identity header.
|
|
@@ -239,10 +244,17 @@ export async function classifyAppSessionFromCookie(headers, config) {
|
|
|
239
244
|
// ceiling against a malformed response: 400 days is the maximum cookie Max-Age browsers
|
|
240
245
|
// honor (RFC 6265bis), so it never truncates a configured absolute lifetime.
|
|
241
246
|
const MAX_COOKIE_AGE_SECONDS = 400 * 24 * 60 * 60;
|
|
242
|
-
/** Exchanges a one-time
|
|
243
|
-
|
|
244
|
-
|
|
247
|
+
/** Exchanges a one-time Core code using this app's service credential. */
|
|
248
|
+
export async function exchangeAppCode(code, codeVerifier) {
|
|
249
|
+
if (!isValidCodeVerifier(codeVerifier))
|
|
250
|
+
return { ok: false, status: 400, code: "app_auth_proof_required", message: "A valid Hosty sign-in proof is required." };
|
|
251
|
+
const serviceToken = getServiceToken();
|
|
252
|
+
if (!serviceToken) {
|
|
253
|
+
return { ok: false, status: 503, code: "app_service_token_missing", message: "HOSTY_APP_SERVICE_TOKEN is not configured." };
|
|
254
|
+
}
|
|
245
255
|
const coreOrigin = getCoreOrigin();
|
|
256
|
+
if (appAuthProtocolMinimum(coreOrigin) === 2 && await getAppAuthProtocol() !== 2)
|
|
257
|
+
return { ok: false, status: 503, code: "app_auth_protocol_unavailable", message: "Hosty sign-in protocol could not be verified." };
|
|
246
258
|
let endpoint;
|
|
247
259
|
try {
|
|
248
260
|
endpoint = new URL("/api/auth/apps/token", coreOrigin ?? "").toString();
|
|
@@ -254,9 +266,10 @@ export async function exchangeAppCode(code) {
|
|
|
254
266
|
try {
|
|
255
267
|
response = await fetch(endpoint, {
|
|
256
268
|
method: "POST",
|
|
257
|
-
headers: { "content-type": "application/json" },
|
|
258
|
-
body: JSON.stringify({ code }),
|
|
269
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${serviceToken}` },
|
|
270
|
+
body: JSON.stringify({ code, codeVerifier }),
|
|
259
271
|
cache: "no-store",
|
|
272
|
+
redirect: "error",
|
|
260
273
|
signal: AbortSignal.timeout(CORE_AUTH_TIMEOUT_MS),
|
|
261
274
|
});
|
|
262
275
|
}
|
|
@@ -317,7 +330,10 @@ export function createAppCodeRouteHandler(config) {
|
|
|
317
330
|
if (!code) {
|
|
318
331
|
return jsonResponse({ code: "app_auth_code_required", message: "A Hosty app authorization code is required." }, 422);
|
|
319
332
|
}
|
|
320
|
-
const
|
|
333
|
+
const codeVerifier = body && typeof body === "object" ? body.codeVerifier : null;
|
|
334
|
+
if (!isValidCodeVerifier(codeVerifier))
|
|
335
|
+
return jsonResponse({ code: "app_auth_proof_required", message: "A valid Hosty sign-in proof is required." }, 400);
|
|
336
|
+
const exchange = await exchangeAppCode(code, codeVerifier);
|
|
321
337
|
if (!exchange.ok) {
|
|
322
338
|
return jsonResponse({ code: exchange.code, message: exchange.message }, exchange.status);
|
|
323
339
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hosty-sdk/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "Hosty app auth: session state machine, recovery bridge, Core revalidation, and the embedder responder.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./dist/browser-auth.d.ts",
|
|
18
18
|
"default": "./dist/browser-auth.js"
|
|
19
19
|
},
|
|
20
|
+
"./app-code": {
|
|
21
|
+
"types": "./dist/app-code.d.ts",
|
|
22
|
+
"default": "./dist/app-code.js"
|
|
23
|
+
},
|
|
20
24
|
"./delegated": {
|
|
21
25
|
"types": "./dist/delegated.d.ts",
|
|
22
26
|
"default": "./dist/delegated.js"
|
|
@@ -83,10 +87,11 @@
|
|
|
83
87
|
"build": "tsc -p tsconfig.build.json && node scripts/fix-dist-extensions.mjs"
|
|
84
88
|
},
|
|
85
89
|
"dependencies": {
|
|
90
|
+
"@noble/hashes": "^2.4.0",
|
|
86
91
|
"server-only": "^0.0.1"
|
|
87
92
|
},
|
|
88
93
|
"peerDependencies": {
|
|
89
|
-
"next": "^16.3.
|
|
94
|
+
"next": "^16.3.8",
|
|
90
95
|
"react": "^19.2.8"
|
|
91
96
|
},
|
|
92
97
|
"peerDependenciesMeta": {
|
|
@@ -98,14 +103,14 @@
|
|
|
98
103
|
}
|
|
99
104
|
},
|
|
100
105
|
"devDependencies": {
|
|
101
|
-
"@types/node": "^24.19.
|
|
106
|
+
"@types/node": "^24.19.1",
|
|
102
107
|
"@types/react": "^19.3.0",
|
|
103
|
-
"
|
|
104
|
-
"
|
|
108
|
+
"@types/react-dom": "^19.3.0",
|
|
109
|
+
"jsdom": "^30.1.1",
|
|
105
110
|
"react": "^19.3.0",
|
|
106
111
|
"react-dom": "^19.3.0",
|
|
107
|
-
"
|
|
108
|
-
"
|
|
112
|
+
"typescript": "^6",
|
|
113
|
+
"vitest": "^5.0.3"
|
|
109
114
|
},
|
|
110
115
|
"repository": {
|
|
111
116
|
"type": "git",
|