@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/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The postMessage type an embedded app
|
|
2
|
+
* The postMessage type for an embedded app's authentication-required intent.
|
|
3
3
|
* A frozen protocol constant: it deliberately does not track product branding (precedent:
|
|
4
4
|
* the `x-docker-host-identity` header survived the docker-host→hosty rename untouched).
|
|
5
5
|
* The payload carries no secret — the embedder verifies the sender before acting.
|
|
@@ -13,8 +13,8 @@ export declare function createAuthRequiredIntent(appId: string): AuthRequiredInt
|
|
|
13
13
|
/**
|
|
14
14
|
* The postMessage types of the delegated-token handshake: an embedded app page asks its embedder
|
|
15
15
|
* for a short-TTL token scoped to itself, and the embedder answers with one it minted from Core.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Minting a delegated token uses the embedder's first-party Core session. The app's own identity
|
|
17
|
+
* sign-in is separate: it navigates directly to Core or opens its own popup.
|
|
18
18
|
*
|
|
19
19
|
* The request carries no secret, so it is safe to broadcast; the answer does, so an embedder must
|
|
20
20
|
* post it to the frame's own origin and never to `*`. Answering is a decision, not a reflex: the
|
|
@@ -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. */
|
|
@@ -126,8 +127,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
|
|
|
126
127
|
/**
|
|
127
128
|
* How a shell is presenting this app.
|
|
128
129
|
*
|
|
129
|
-
* - `embedded` — framed by the browser Shell. The shell renders navigation for this app
|
|
130
|
-
*
|
|
130
|
+
* - `embedded` — framed by the browser Shell. The shell renders navigation for this app. The
|
|
131
|
+
* app restores its per-tab grant, tries a silent Core redirect on first load, then uses its
|
|
132
|
+
* own Core popup when interaction is required. No credential passes through the parent.
|
|
131
133
|
* - `native` — the top frame inside a native shell's web view (`apps/shell-swift`). The shell
|
|
132
134
|
* renders navigation, but there is no parent to post to, so recovery takes the standalone
|
|
133
135
|
* redirect — which is exactly the navigation that client intercepts to re-mint a launch code.
|
|
@@ -135,10 +137,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
|
|
|
135
137
|
* for this app, so it keeps its own.
|
|
136
138
|
*
|
|
137
139
|
* Two independent decisions read this one value and they do not split it the same way: chrome
|
|
138
|
-
* is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
* hidden would send recovery to a parent that is not there.
|
|
140
|
+
* is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while frame-local silent
|
|
141
|
+
* sign-in and popup recovery belong to `embedded` alone (`AppIdentityBridge`). A native web
|
|
142
|
+
* view keeps standalone recovery even though its app chrome is hidden.
|
|
142
143
|
*/
|
|
143
144
|
export type AppLaunchMode = "embedded" | "native" | "standalone";
|
|
144
145
|
/** The query parameter a shell appends to the launch URL to declare the mode. */
|
|
@@ -160,9 +161,8 @@ export declare function normalizeLaunchMode(value: unknown): AppLaunchMode | nul
|
|
|
160
161
|
* `standalone` — a native shell's web view makes the app the top frame, so it reads as
|
|
161
162
|
* `standalone`, which is why the declared `hosty_launch` parameter exists at all.
|
|
162
163
|
*
|
|
163
|
-
* This stays the input to the *recovery* decision, because
|
|
164
|
-
*
|
|
165
|
-
* post into a window with no shell listening.
|
|
164
|
+
* This stays the input to the *recovery* decision, because framing is a structural fact a
|
|
165
|
+
* declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
|
|
166
166
|
*/
|
|
167
167
|
export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): Exclude<AppLaunchMode, "native">;
|
|
168
168
|
export interface LaunchModeResolution {
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// This is the single source of truth for the Hosty app-session contract described in
|
|
5
5
|
// docker-host docs/features/hosty-app-sdk/feature.md and docs/features/auth-session-lifecycle/feature.md.
|
|
6
6
|
/**
|
|
7
|
-
* The postMessage type an embedded app
|
|
7
|
+
* The postMessage type for an embedded app's authentication-required intent.
|
|
8
8
|
* A frozen protocol constant: it deliberately does not track product branding (precedent:
|
|
9
9
|
* the `x-docker-host-identity` header survived the docker-host→hosty rename untouched).
|
|
10
10
|
* The payload carries no secret — the embedder verifies the sender before acting.
|
|
@@ -16,8 +16,8 @@ export function createAuthRequiredIntent(appId) {
|
|
|
16
16
|
/**
|
|
17
17
|
* The postMessage types of the delegated-token handshake: an embedded app page asks its embedder
|
|
18
18
|
* for a short-TTL token scoped to itself, and the embedder answers with one it minted from Core.
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Minting a delegated token uses the embedder's first-party Core session. The app's own identity
|
|
20
|
+
* sign-in is separate: it navigates directly to Core or opens its own popup.
|
|
21
21
|
*
|
|
22
22
|
* The request carries no secret, so it is safe to broadcast; the answer does, so an embedder must
|
|
23
23
|
* post it to the frame's own origin and never to `*`. Answering is a decision, not a reflex: the
|
|
@@ -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
|
|
@@ -152,9 +153,8 @@ export function normalizeLaunchMode(value) {
|
|
|
152
153
|
* `standalone` — a native shell's web view makes the app the top frame, so it reads as
|
|
153
154
|
* `standalone`, which is why the declared `hosty_launch` parameter exists at all.
|
|
154
155
|
*
|
|
155
|
-
* This stays the input to the *recovery* decision, because
|
|
156
|
-
*
|
|
157
|
-
* post into a window with no shell listening.
|
|
156
|
+
* This stays the input to the *recovery* decision, because framing is a structural fact a
|
|
157
|
+
* declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
|
|
158
158
|
*/
|
|
159
159
|
export function detectLaunchMode(win) {
|
|
160
160
|
try {
|
|
@@ -241,7 +241,8 @@ export function decideRecoveryAction(input) {
|
|
|
241
241
|
}
|
|
242
242
|
// expired / not-present — the recoverable pair.
|
|
243
243
|
//
|
|
244
|
-
// `embedded`
|
|
244
|
+
// `embedded` selects app-owned interactive recovery after the bridge's silent first-load path.
|
|
245
|
+
// `native` deliberately falls through to the
|
|
245
246
|
// redirect beside `standalone`: a native shell's web view has no parent, and the redirect to
|
|
246
247
|
// Core's `/open` is the navigation that client watches for to re-mint a launch code. Adding a
|
|
247
248
|
// mode must never quietly add a case here — a mode that cannot reach a shell has to redirect.
|
package/dist/react.d.ts
CHANGED
|
@@ -55,7 +55,8 @@ export interface AppIdentityBridgeProps {
|
|
|
55
55
|
/**
|
|
56
56
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
57
57
|
* the URL (exchanging it for the identity cookie), then probes the session and runs the
|
|
58
|
-
* recovery decision:
|
|
58
|
+
* recovery decision: a silent first-load Core navigation followed by an app-owned popup
|
|
59
|
+
* when embedded, a once-per-tab
|
|
59
60
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
60
61
|
* — never a login UI while recovery is still running.
|
|
61
62
|
*/
|
package/dist/react.js
CHANGED
|
@@ -6,12 +6,14 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
6
6
|
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
|
-
import { appFetch, appSessionActive, rememberAppGrant, openAppSignIn, APP_SESSION_ENDED } from "./browser-auth.js";
|
|
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";
|
|
16
|
+
const SILENT_GUARD_PREFIX = "hosty.auth.silent-attempted:";
|
|
15
17
|
// Cap the status probe so a stalled request cannot leave the bridge stuck hidden — on
|
|
16
18
|
// timeout it classifies as unavailable and the user gets a Retry affordance.
|
|
17
19
|
const IDENTITY_PROBE_TIMEOUT_MS = 4_000;
|
|
@@ -66,6 +68,42 @@ function writeGuard(value) {
|
|
|
66
68
|
// sessionStorage may be blocked; recovery still works, only the guard is lost.
|
|
67
69
|
}
|
|
68
70
|
}
|
|
71
|
+
/** Both state and the per-app guard must survive navigation, or the popup is the fallback. */
|
|
72
|
+
function beginNavigationSignIn(openUrl, appId, protocol, mode, force = false) {
|
|
73
|
+
let state = null;
|
|
74
|
+
try {
|
|
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))
|
|
81
|
+
return false;
|
|
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);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
if (state) {
|
|
99
|
+
try {
|
|
100
|
+
window.sessionStorage.removeItem(`${APP_AUTH_ATTEMPT_PREFIX}${state}`);
|
|
101
|
+
}
|
|
102
|
+
catch { /* Popup fallback. */ }
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
69
107
|
function readStoredLaunchMode() {
|
|
70
108
|
try {
|
|
71
109
|
return window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
|
|
@@ -76,7 +114,7 @@ function readStoredLaunchMode() {
|
|
|
76
114
|
}
|
|
77
115
|
/** The mode already applied to the document, or a fresh resolution when nothing applied one. */
|
|
78
116
|
function currentLaunchMode() {
|
|
79
|
-
const applied = normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
|
|
117
|
+
const applied = typeof document === "undefined" ? null : normalizeLaunchMode(document.documentElement.getAttribute(LAUNCH_MODE_ATTRIBUTE));
|
|
80
118
|
if (applied) {
|
|
81
119
|
return applied;
|
|
82
120
|
}
|
|
@@ -144,7 +182,8 @@ export function useLaunchMode() {
|
|
|
144
182
|
/**
|
|
145
183
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
146
184
|
* the URL (exchanging it for the identity cookie), then probes the session and runs the
|
|
147
|
-
* recovery decision:
|
|
185
|
+
* recovery decision: a silent first-load Core navigation followed by an app-owned popup
|
|
186
|
+
* when embedded, a once-per-tab
|
|
148
187
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
149
188
|
* — never a login UI while recovery is still running.
|
|
150
189
|
*/
|
|
@@ -153,11 +192,18 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
153
192
|
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
154
193
|
// keep its exchange alive and let the replacement effect await the same response.
|
|
155
194
|
const wasActive = useRef(false);
|
|
195
|
+
const firstProbe = useRef(true);
|
|
196
|
+
const initialGrantPresent = useRef(null);
|
|
197
|
+
const silentError = useRef(null);
|
|
198
|
+
const returnedLegacyAttempt = useRef(null);
|
|
199
|
+
const upgradedAttempt = useRef(false);
|
|
156
200
|
const [activity, setActivity] = useState(null);
|
|
157
201
|
const exchangeRef = useRef(null);
|
|
158
202
|
useEffect(() => {
|
|
159
203
|
let cancelled = false;
|
|
160
204
|
const controller = new AbortController();
|
|
205
|
+
if (initialGrantPresent.current === null)
|
|
206
|
+
initialGrantPresent.current = restoreAppGrant();
|
|
161
207
|
async function probeAndRecover() {
|
|
162
208
|
// Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
|
|
163
209
|
// available in every browser (a synchronous throw here would stop the bridge from
|
|
@@ -169,6 +215,10 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
169
215
|
let status = null;
|
|
170
216
|
let openUrl = null;
|
|
171
217
|
let appId = null;
|
|
218
|
+
let protocol = null;
|
|
219
|
+
let activityMetadata = null;
|
|
220
|
+
let rejectionCode;
|
|
221
|
+
const revision = appGrantRevision();
|
|
172
222
|
try {
|
|
173
223
|
const response = await appFetch(probePath, {
|
|
174
224
|
headers: { Accept: "application/json" },
|
|
@@ -177,12 +227,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
177
227
|
}, false);
|
|
178
228
|
const body = await response.json().catch(() => null);
|
|
179
229
|
status = readProbedSessionStatus(body);
|
|
230
|
+
const identityError = body;
|
|
231
|
+
rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
|
|
180
232
|
const recovery = readRecoveryParams(body);
|
|
181
233
|
appId = recovery.appId;
|
|
182
234
|
openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
setActivity({ openUrl, activeUntil: metadata?.activeUntil, activityRequired: metadata?.activityRequired });
|
|
235
|
+
protocol = openUrl ? acceptAppAuthProtocol(new URL(openUrl).origin, recovery.appAuthProtocol) : recovery.appAuthProtocol;
|
|
236
|
+
activityMetadata = body;
|
|
186
237
|
}
|
|
187
238
|
catch {
|
|
188
239
|
// A failed or timed-out probe (Core unreachable) classifies as unavailable below.
|
|
@@ -195,14 +246,46 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
195
246
|
if (cancelled) {
|
|
196
247
|
return;
|
|
197
248
|
}
|
|
198
|
-
|
|
249
|
+
if (revision !== appGrantRevision()) {
|
|
250
|
+
initialGrantPresent.current ||= restoreAppGrant();
|
|
251
|
+
void probeAndRecover();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
forgetRejectedAppGrant(rejectionCode, revision);
|
|
255
|
+
if (openUrl && protocol)
|
|
256
|
+
setActivity({ openUrl, appAuthProtocol: protocol,
|
|
257
|
+
activeUntil: activityMetadata?.activeUntil, activityRequired: activityMetadata?.activityRequired });
|
|
258
|
+
const initialLoad = firstProbe.current;
|
|
259
|
+
firstProbe.current = false;
|
|
260
|
+
if (silentError.current === "access_denied") {
|
|
261
|
+
setUi({ kind: "denied" });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const mode = currentLaunchMode();
|
|
265
|
+
const legacy = returnedLegacyAttempt.current;
|
|
266
|
+
const sameLegacyCore = !!legacy && appId === legacy.appId && !!openUrl && new URL(openUrl).origin === legacy.coreOrigin;
|
|
267
|
+
if (legacy?.protocolRequired && (status === "not-present" || status === "expired") && (!sameLegacyCore || protocol !== 2)) {
|
|
268
|
+
setUi({ kind: "unavailable" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const shouldUpgrade = sameLegacyCore && protocol === 2 && !upgradedAttempt.current;
|
|
272
|
+
if (shouldUpgrade) {
|
|
273
|
+
upgradedAttempt.current = true;
|
|
274
|
+
returnedLegacyAttempt.current = null;
|
|
275
|
+
}
|
|
276
|
+
if (!wasActive.current && protocol && ((initialLoad && !initialGrantPresent.current && !silentError.current) || shouldUpgrade) &&
|
|
277
|
+
detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
|
|
278
|
+
if (beginNavigationSignIn(openUrl, appId, protocol, "silent", shouldUpgrade))
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const action = !status || !appId || ((status === "not-present" || status === "expired") && !protocol)
|
|
199
282
|
? { kind: "card", card: "unavailable" }
|
|
200
283
|
: decideRecoveryAction({
|
|
201
|
-
status,
|
|
202
|
-
mode
|
|
284
|
+
status: silentError.current === "login_required" ? "not-present" : status,
|
|
285
|
+
mode,
|
|
203
286
|
appId,
|
|
204
287
|
openUrl,
|
|
205
|
-
redirectAlreadyAttempted: readGuard(),
|
|
288
|
+
redirectAlreadyAttempted: !shouldUpgrade && readGuard(),
|
|
206
289
|
});
|
|
207
290
|
switch (action.kind) {
|
|
208
291
|
case "none":
|
|
@@ -211,40 +294,50 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
211
294
|
setUi({ kind: "active" });
|
|
212
295
|
return;
|
|
213
296
|
case "post-auth-required": {
|
|
214
|
-
const showSignIn = (error) => setUi({ kind: "signin", openUrl, embedded: true, error,
|
|
215
|
-
signIn: openUrl ? () => {
|
|
216
|
-
// Open synchronously in the click handler; an awaited request loses the user gesture.
|
|
217
|
-
const result = openAppSignIn(openUrl, controller.signal);
|
|
218
|
-
setUi({ kind: "recovering" });
|
|
219
|
-
void result.then(async (code) => {
|
|
220
|
-
const response = await appFetch(appCodePath, {
|
|
221
|
-
method: "POST", headers: { "Content-Type": "application/json" },
|
|
222
|
-
body: JSON.stringify({ code }),
|
|
223
|
-
});
|
|
224
|
-
const body = await response.json();
|
|
225
|
-
if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
|
|
226
|
-
throw new Error("Hosty could not establish this app session.");
|
|
227
|
-
if (cancelled)
|
|
228
|
-
return;
|
|
229
|
-
rememberAppGrant(body.accessToken);
|
|
230
|
-
writeGuard(false);
|
|
231
|
-
await probeAndRecover();
|
|
232
|
-
}).catch(error => { if (!cancelled)
|
|
233
|
-
showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
|
|
234
|
-
} : undefined });
|
|
235
297
|
showSignIn();
|
|
236
298
|
return;
|
|
237
299
|
}
|
|
238
300
|
case "redirect":
|
|
239
|
-
|
|
240
|
-
|
|
301
|
+
if (appId && protocol && beginNavigationSignIn(action.openUrl, appId, protocol, mode === "native" ? "native" : "standalone", shouldUpgrade))
|
|
302
|
+
return;
|
|
303
|
+
showSignIn("Allow app storage, or sign in with a popup.");
|
|
241
304
|
return;
|
|
242
305
|
case "card":
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
306
|
+
if (action.card === "signin")
|
|
307
|
+
showSignIn();
|
|
308
|
+
else
|
|
309
|
+
setUi({ kind: action.card });
|
|
246
310
|
return;
|
|
247
311
|
}
|
|
312
|
+
function showSignIn(error) {
|
|
313
|
+
setUi({ kind: "signin", openUrl, embedded: mode === "embedded", error,
|
|
314
|
+
signIn: openUrl && protocol ? () => {
|
|
315
|
+
// Open synchronously in the click handler; an awaited request loses the user gesture.
|
|
316
|
+
const revision = appGrantRevision();
|
|
317
|
+
const result = openAppSignIn(openUrl, controller.signal, protocol, mode === "native", probePath);
|
|
318
|
+
setUi({ kind: "recovering" });
|
|
319
|
+
void result.then(async (proof) => {
|
|
320
|
+
const response = await appFetch(appCodePath, {
|
|
321
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
322
|
+
body: JSON.stringify(proof),
|
|
323
|
+
}, false);
|
|
324
|
+
const body = await response.json();
|
|
325
|
+
if (!response.ok || typeof body.accessToken !== "string" || !body.accessToken)
|
|
326
|
+
throw new Error("Hosty could not establish this app session.");
|
|
327
|
+
if (cancelled)
|
|
328
|
+
return;
|
|
329
|
+
if (revision !== appGrantRevision()) {
|
|
330
|
+
await probeAndRecover();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
rememberAppGrant(body.accessToken);
|
|
334
|
+
silentError.current = null;
|
|
335
|
+
writeGuard(false);
|
|
336
|
+
await probeAndRecover();
|
|
337
|
+
}).catch(error => { if (!cancelled)
|
|
338
|
+
showSignIn(error instanceof Error ? error.message : "Sign-in failed."); });
|
|
339
|
+
} : undefined });
|
|
340
|
+
}
|
|
248
341
|
}
|
|
249
342
|
const recover = () => { if (!cancelled && !wasActive.current) {
|
|
250
343
|
setUi({ kind: "recovering" });
|
|
@@ -253,15 +346,31 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
253
346
|
window.addEventListener(APP_SESSION_ENDED, recover);
|
|
254
347
|
const url = new URL(window.location.href);
|
|
255
348
|
const code = url.searchParams.get("code")?.trim();
|
|
256
|
-
|
|
349
|
+
const error = url.searchParams.get("error");
|
|
350
|
+
const isSilentError = error === "login_required" || error === "access_denied";
|
|
351
|
+
const attempt = takeAppAuthAttempt(url, true);
|
|
352
|
+
const upgradedLegacy = attempt?.protocol === 1 && acceptAppAuthProtocol(attempt.coreOrigin, 1) === null;
|
|
353
|
+
if (attempt?.mode === "silent" && isSilentError)
|
|
354
|
+
silentError.current = error;
|
|
355
|
+
const protocolRequired = attempt?.protocol === 1 && !code && error === "protocol_required" && url.searchParams.getAll("error").length === 1;
|
|
356
|
+
if (attempt?.protocol === 1 && (!error || protocolRequired || upgradedLegacy))
|
|
357
|
+
returnedLegacyAttempt.current = { appId: attempt.appId, coreOrigin: attempt.coreOrigin, protocolRequired: protocolRequired || upgradedLegacy };
|
|
358
|
+
if (code || isSilentError || attempt) {
|
|
257
359
|
url.searchParams.delete("code");
|
|
360
|
+
url.searchParams.delete("state");
|
|
361
|
+
if (isSilentError || protocolRequired)
|
|
362
|
+
url.searchParams.delete("error");
|
|
258
363
|
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
364
|
+
}
|
|
365
|
+
if (code && !error && !upgradedLegacy && attempt && !exchangeRef.current) {
|
|
259
366
|
exchangeRef.current = {
|
|
260
367
|
path: appCodePath,
|
|
368
|
+
revision: appGrantRevision(),
|
|
261
369
|
response: fetch(appCodePath, {
|
|
262
370
|
method: "POST",
|
|
263
371
|
headers: { "Content-Type": "application/json" },
|
|
264
|
-
body: JSON.stringify({ code }),
|
|
372
|
+
body: JSON.stringify({ code, codeVerifier: attempt.codeVerifier }),
|
|
373
|
+
credentials: "same-origin", redirect: "error",
|
|
265
374
|
}),
|
|
266
375
|
};
|
|
267
376
|
}
|
|
@@ -275,6 +384,13 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
275
384
|
if (response.ok) {
|
|
276
385
|
writeGuard(false);
|
|
277
386
|
const body = await response.json().catch(() => null);
|
|
387
|
+
if (cancelled)
|
|
388
|
+
return;
|
|
389
|
+
if (exchange.revision !== appGrantRevision()) {
|
|
390
|
+
exchangeRef.current = null;
|
|
391
|
+
await probeAndRecover();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
278
394
|
if (typeof body?.accessToken === "string")
|
|
279
395
|
rememberAppGrant(body.accessToken);
|
|
280
396
|
exchangeRef.current = null;
|
|
@@ -302,7 +418,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
302
418
|
};
|
|
303
419
|
}, [probePath, appCodePath]);
|
|
304
420
|
if (wasActive.current)
|
|
305
|
-
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
421
|
+
return _jsxs(_Fragment, { children: [activity && _jsx(AppActivityBridge, { ...activity, appCodePath: appCodePath, probePath: probePath }), renderState ? renderState({ kind: "active" }) : children] });
|
|
306
422
|
if (renderState)
|
|
307
423
|
return renderState(ui);
|
|
308
424
|
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.0",
|
|
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,6 +87,7 @@
|
|
|
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": {
|