@hosty-sdk/app 0.19.1 → 0.20.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/browser-auth.d.ts +7 -0
- package/dist/browser-auth.js +74 -7
- package/dist/embedder.d.ts +2 -1
- package/dist/embedder.js +6 -5
- package/dist/index.d.ts +11 -12
- package/dist/index.js +7 -7
- package/dist/react.d.ts +2 -1
- package/dist/react.js +84 -4
- package/package.json +1 -1
package/dist/browser-auth.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export declare const APP_SESSION_ENDED = "hosty:app-session-ended";
|
|
2
|
+
export declare const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
|
|
3
|
+
/** Restore before the first identity probe; standalone documents use their first-party cookie. */
|
|
4
|
+
export declare function restoreAppGrant(): boolean;
|
|
2
5
|
export declare function rememberAppGrant(token: string): void;
|
|
3
6
|
export declare function forgetAppGrant(): void;
|
|
7
|
+
/** Snapshot the grant generation so older probes cannot invalidate a renewed identity. */
|
|
8
|
+
export declare function appGrantRevision(): number;
|
|
9
|
+
/** Activity expiry keeps identity; only explicit identity rejection discards the credential. */
|
|
10
|
+
export declare function forgetRejectedAppGrant(code: unknown, expectedRevision?: number): boolean;
|
|
4
11
|
export declare function appSessionActive(): void;
|
|
5
12
|
/** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
|
|
6
13
|
export declare function appFetch(input: string | URL, init?: RequestInit, recover?: boolean): Promise<Response>;
|
package/dist/browser-auth.js
CHANGED
|
@@ -1,12 +1,71 @@
|
|
|
1
|
-
/** App-only
|
|
1
|
+
/** App-only credentials. Embedded documents keep them for this tab on the app's own origin. */
|
|
2
2
|
let grant = null;
|
|
3
|
+
let grantRevision = 0;
|
|
3
4
|
let recoveryPending = false;
|
|
4
5
|
export const APP_SESSION_ENDED = "hosty:app-session-ended";
|
|
6
|
+
export const APP_GRANT_STORAGE_KEY = "hosty.auth.app-grant";
|
|
7
|
+
function isEmbedded() {
|
|
8
|
+
try {
|
|
9
|
+
return window.self !== window.top;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Restore before the first identity probe; standalone documents use their first-party cookie. */
|
|
16
|
+
export function restoreAppGrant() {
|
|
17
|
+
if (typeof window === "undefined")
|
|
18
|
+
return false;
|
|
19
|
+
if (grant?.origin === window.location.origin)
|
|
20
|
+
return true;
|
|
21
|
+
if (!isEmbedded())
|
|
22
|
+
return false;
|
|
23
|
+
try {
|
|
24
|
+
const token = window.sessionStorage.getItem(APP_GRANT_STORAGE_KEY);
|
|
25
|
+
if (token) {
|
|
26
|
+
grant = { token, origin: window.location.origin };
|
|
27
|
+
grantRevision++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Blocked storage leaves the in-memory transport available.
|
|
32
|
+
}
|
|
33
|
+
return grant?.origin === window.location.origin;
|
|
34
|
+
}
|
|
5
35
|
export function rememberAppGrant(token) {
|
|
6
36
|
grant = { token, origin: window.location.origin };
|
|
37
|
+
grantRevision++;
|
|
7
38
|
recoveryPending = false;
|
|
39
|
+
if (isEmbedded()) {
|
|
40
|
+
try {
|
|
41
|
+
window.sessionStorage.setItem(APP_GRANT_STORAGE_KEY, token);
|
|
42
|
+
}
|
|
43
|
+
catch { /* In-memory fallback. */ }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function forgetAppGrant() {
|
|
47
|
+
if (grant)
|
|
48
|
+
grantRevision++;
|
|
49
|
+
grant = null;
|
|
50
|
+
recoveryPending = false;
|
|
51
|
+
if (typeof window !== "undefined") {
|
|
52
|
+
try {
|
|
53
|
+
window.sessionStorage.removeItem(APP_GRANT_STORAGE_KEY);
|
|
54
|
+
}
|
|
55
|
+
catch { /* In-memory fallback. */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Snapshot the grant generation so older probes cannot invalidate a renewed identity. */
|
|
59
|
+
export function appGrantRevision() { return grantRevision; }
|
|
60
|
+
/** Activity expiry keeps identity; only explicit identity rejection discards the credential. */
|
|
61
|
+
export function forgetRejectedAppGrant(code, expectedRevision = grantRevision) {
|
|
62
|
+
if (expectedRevision !== grantRevision)
|
|
63
|
+
return false;
|
|
64
|
+
if (typeof code !== "string" || !["token_invalid", "token_revoked", "token_expired", "token_app_mismatch"].includes(code))
|
|
65
|
+
return false;
|
|
66
|
+
forgetAppGrant();
|
|
67
|
+
return true;
|
|
8
68
|
}
|
|
9
|
-
export function forgetAppGrant() { grant = null; recoveryPending = false; }
|
|
10
69
|
export function appSessionActive() { recoveryPending = false; }
|
|
11
70
|
/** Use for app-local API calls, including streams. Redirects cannot carry the credential away. */
|
|
12
71
|
export async function appFetch(input, init = {}, recover = true) {
|
|
@@ -20,11 +79,21 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
20
79
|
if (current)
|
|
21
80
|
headers.set("authorization", `Bearer ${current.token}`);
|
|
22
81
|
const response = await fetch(input, { ...init, headers, credentials: "same-origin", redirect: "error" });
|
|
23
|
-
if (response.status === 401
|
|
82
|
+
if ((response.status === 401 || response.status === 403) && grant === current && !init.signal?.aborted) {
|
|
83
|
+
const revision = grantRevision;
|
|
24
84
|
const body = await response.clone().json().catch(() => null);
|
|
85
|
+
if (grant !== current || grantRevision !== revision || init.signal?.aborted)
|
|
86
|
+
return response;
|
|
87
|
+
// Identity probes disable recovery, but a rejected grant must still be forgotten. Activity
|
|
88
|
+
// expiry is different: the identity remains valid and the popup renews the same session.
|
|
89
|
+
const rejected = forgetRejectedAppGrant(body?.code);
|
|
90
|
+
if (response.status !== 401)
|
|
91
|
+
return response;
|
|
92
|
+
if (!rejected && recover && body?.code !== "reauth_required" && current)
|
|
93
|
+
forgetAppGrant();
|
|
94
|
+
if (!recover)
|
|
95
|
+
return response;
|
|
25
96
|
if (activityConfig) {
|
|
26
|
-
if (body?.code !== "reauth_required" && current)
|
|
27
|
-
grant = null;
|
|
28
97
|
const renewed = waitForActivity(init.signal);
|
|
29
98
|
window.dispatchEvent(new Event(APP_SESSION_ENDED));
|
|
30
99
|
// A response may arrive while the originating click still has browser activation.
|
|
@@ -35,8 +104,6 @@ export async function appFetch(input, init = {}, recover = true) {
|
|
|
35
104
|
return appFetch(input, init, false);
|
|
36
105
|
}
|
|
37
106
|
else if (!recoveryPending) {
|
|
38
|
-
if (current)
|
|
39
|
-
grant = null;
|
|
40
107
|
recoveryPending = true;
|
|
41
108
|
window.dispatchEvent(new Event(APP_SESSION_ENDED));
|
|
42
109
|
}
|
package/dist/embedder.d.ts
CHANGED
|
@@ -13,7 +13,8 @@ export type AuthRequiredMessage = EmbedderMessage;
|
|
|
13
13
|
* - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
|
|
14
14
|
* - the payload's `appId` must match the app the embedder actually mounted there.
|
|
15
15
|
* No credential crosses the boundary in either direction: the payload is only the app id,
|
|
16
|
-
* and the
|
|
16
|
+
* and the app owns its direct sign-in with Core. This parser gives the embedder no authority
|
|
17
|
+
* to issue or redeem credentials for the app.
|
|
17
18
|
*/
|
|
18
19
|
export declare function parseActiveFrameAuthRequired(event: AuthRequiredMessage, activeFrameWindow: unknown, activeFrameUrl: string, expectedAppId: string): boolean;
|
|
19
20
|
/** A verified request, and the one thing its payload carries. */
|
package/dist/embedder.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// functions plus a small rate limiter — the embedder wires them to its own DOM, its own "open
|
|
4
4
|
// this app" flow, and its own Core session.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Authentication belongs to the app frame: it restores its own per-tab grant, tries a silent
|
|
7
|
+
// Core navigation on first load, and opens its own Core popup when interaction is required.
|
|
8
|
+
// An embedder may observe a verified `hosty:auth-required` intent, but never carries or
|
|
9
|
+
// redeems another app's code or grant.
|
|
10
10
|
//
|
|
11
11
|
// Delegated tokens, in one sentence: on a VERIFIED `hosty:request-delegated-token` from an app
|
|
12
12
|
// you have decided to grant one, mint a token from Core with your own session and post it back
|
|
@@ -21,7 +21,8 @@ import { THEME_PARAM, THEME_PREFERENCE_PARAM } from "./theme.js";
|
|
|
21
21
|
* - `event.origin` must match the frame URL's origin (the app itself, not an inner frame);
|
|
22
22
|
* - the payload's `appId` must match the app the embedder actually mounted there.
|
|
23
23
|
* No credential crosses the boundary in either direction: the payload is only the app id,
|
|
24
|
-
* and the
|
|
24
|
+
* and the app owns its direct sign-in with Core. This parser gives the embedder no authority
|
|
25
|
+
* to issue or redeem credentials for the app.
|
|
25
26
|
*/
|
|
26
27
|
export function parseActiveFrameAuthRequired(event, activeFrameWindow, activeFrameUrl, expectedAppId) {
|
|
27
28
|
if (!isActiveFrameMessage(event, activeFrameWindow, activeFrameUrl)) {
|
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
|
|
@@ -126,8 +126,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
|
|
|
126
126
|
/**
|
|
127
127
|
* How a shell is presenting this app.
|
|
128
128
|
*
|
|
129
|
-
* - `embedded` — framed by the browser Shell. The shell renders navigation for this app
|
|
130
|
-
*
|
|
129
|
+
* - `embedded` — framed by the browser Shell. The shell renders navigation for this app. The
|
|
130
|
+
* app restores its per-tab grant, tries a silent Core redirect on first load, then uses its
|
|
131
|
+
* own Core popup when interaction is required. No credential passes through the parent.
|
|
131
132
|
* - `native` — the top frame inside a native shell's web view (`apps/shell-swift`). The shell
|
|
132
133
|
* renders navigation, but there is no parent to post to, so recovery takes the standalone
|
|
133
134
|
* redirect — which is exactly the navigation that client intercepts to re-mint a launch code.
|
|
@@ -135,10 +136,9 @@ export declare function buildCoreOpenUrl(corePublicOrigin: string | null, appId:
|
|
|
135
136
|
* for this app, so it keeps its own.
|
|
136
137
|
*
|
|
137
138
|
* 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.
|
|
139
|
+
* is hidden whenever the mode is not `standalone` (`hidesAppChrome`), while frame-local silent
|
|
140
|
+
* sign-in and popup recovery belong to `embedded` alone (`AppIdentityBridge`). A native web
|
|
141
|
+
* view keeps standalone recovery even though its app chrome is hidden.
|
|
142
142
|
*/
|
|
143
143
|
export type AppLaunchMode = "embedded" | "native" | "standalone";
|
|
144
144
|
/** The query parameter a shell appends to the launch URL to declare the mode. */
|
|
@@ -160,9 +160,8 @@ export declare function normalizeLaunchMode(value: unknown): AppLaunchMode | nul
|
|
|
160
160
|
* `standalone` — a native shell's web view makes the app the top frame, so it reads as
|
|
161
161
|
* `standalone`, which is why the declared `hosty_launch` parameter exists at all.
|
|
162
162
|
*
|
|
163
|
-
* This stays the input to the *recovery* decision, because
|
|
164
|
-
*
|
|
165
|
-
* post into a window with no shell listening.
|
|
163
|
+
* This stays the input to the *recovery* decision, because framing is a structural fact a
|
|
164
|
+
* declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
|
|
166
165
|
*/
|
|
167
166
|
export declare function detectLaunchMode(win: Pick<Window, "self" | "top">): Exclude<AppLaunchMode, "native">;
|
|
168
167
|
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
|
|
@@ -152,9 +152,8 @@ export function normalizeLaunchMode(value) {
|
|
|
152
152
|
* `standalone` — a native shell's web view makes the app the top frame, so it reads as
|
|
153
153
|
* `standalone`, which is why the declared `hosty_launch` parameter exists at all.
|
|
154
154
|
*
|
|
155
|
-
* This stays the input to the *recovery* decision, because
|
|
156
|
-
*
|
|
157
|
-
* post into a window with no shell listening.
|
|
155
|
+
* This stays the input to the *recovery* decision, because framing is a structural fact a
|
|
156
|
+
* declared value cannot override: a stale `embedded` must not select frame-only silent sign-in.
|
|
158
157
|
*/
|
|
159
158
|
export function detectLaunchMode(win) {
|
|
160
159
|
try {
|
|
@@ -241,7 +240,8 @@ export function decideRecoveryAction(input) {
|
|
|
241
240
|
}
|
|
242
241
|
// expired / not-present — the recoverable pair.
|
|
243
242
|
//
|
|
244
|
-
// `embedded`
|
|
243
|
+
// `embedded` selects app-owned interactive recovery after the bridge's silent first-load path.
|
|
244
|
+
// `native` deliberately falls through to the
|
|
245
245
|
// redirect beside `standalone`: a native shell's web view has no parent, and the redirect to
|
|
246
246
|
// Core's `/open` is the navigation that client watches for to re-mint a launch code. Adding a
|
|
247
247
|
// 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
10
|
import { buildCoreOpenUrl, decideRecoveryAction, detectLaunchMode, normalizeLaunchMode, readRecoveryParams, resolveLaunchMode, LAUNCH_MODE_ATTRIBUTE, LAUNCH_MODE_PARAM, LAUNCH_MODE_STORAGE_KEY, } from "./index.js";
|
|
11
11
|
import { applyTheme, parseShellThemeMessage, resolveTheme, THEME_PARAM, THEME_PREFERENCE_PARAM, THEME_PREFERENCE_STORAGE_KEY, THEME_STORAGE_KEY, } from "./theme.js";
|
|
12
12
|
// Once-per-tab guard so a standalone app that returns from Core still unauthorized does
|
|
13
13
|
// not bounce through /open forever. Cleared on a successful code exchange.
|
|
14
14
|
const RECOVERY_GUARD_KEY = "hosty.auth.recovery-attempted";
|
|
15
|
+
const SILENT_STATE_KEY = "hosty.auth.silent-state";
|
|
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,40 @@ 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 beginSilentSignIn(openUrl, appId) {
|
|
73
|
+
try {
|
|
74
|
+
const guardKey = `${SILENT_GUARD_PREFIX}${appId}`;
|
|
75
|
+
if (window.sessionStorage.getItem(guardKey) === "1")
|
|
76
|
+
return null;
|
|
77
|
+
const state = Array.from(crypto.getRandomValues(new Uint8Array(32)), b => b.toString(16).padStart(2, "0")).join("");
|
|
78
|
+
window.sessionStorage.setItem(SILENT_STATE_KEY, state);
|
|
79
|
+
window.sessionStorage.setItem(guardKey, "1");
|
|
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))
|
|
97
|
+
return false;
|
|
98
|
+
window.sessionStorage.removeItem(SILENT_STATE_KEY);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
69
105
|
function readStoredLaunchMode() {
|
|
70
106
|
try {
|
|
71
107
|
return window.sessionStorage.getItem(LAUNCH_MODE_STORAGE_KEY);
|
|
@@ -144,7 +180,8 @@ export function useLaunchMode() {
|
|
|
144
180
|
/**
|
|
145
181
|
* Mount once at the top of the root layout body. On first load it consumes a `?code` from
|
|
146
182
|
* the URL (exchanging it for the identity cookie), then probes the session and runs the
|
|
147
|
-
* recovery decision:
|
|
183
|
+
* recovery decision: a silent first-load Core navigation followed by an app-owned popup
|
|
184
|
+
* when embedded, a once-per-tab
|
|
148
185
|
* redirect through Core `/open` when standalone, and cards only in fallback/terminal states
|
|
149
186
|
* — never a login UI while recovery is still running.
|
|
150
187
|
*/
|
|
@@ -153,11 +190,16 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
153
190
|
// A launch code is single-use. Strict Mode replays the effect after the URL is cleaned;
|
|
154
191
|
// keep its exchange alive and let the replacement effect await the same response.
|
|
155
192
|
const wasActive = useRef(false);
|
|
193
|
+
const firstProbe = useRef(true);
|
|
194
|
+
const initialGrantPresent = useRef(null);
|
|
195
|
+
const silentError = useRef(null);
|
|
156
196
|
const [activity, setActivity] = useState(null);
|
|
157
197
|
const exchangeRef = useRef(null);
|
|
158
198
|
useEffect(() => {
|
|
159
199
|
let cancelled = false;
|
|
160
200
|
const controller = new AbortController();
|
|
201
|
+
if (initialGrantPresent.current === null)
|
|
202
|
+
initialGrantPresent.current = restoreAppGrant();
|
|
161
203
|
async function probeAndRecover() {
|
|
162
204
|
// Dedicated controller + setTimeout instead of AbortSignal.any/timeout, which are not
|
|
163
205
|
// available in every browser (a synchronous throw here would stop the bridge from
|
|
@@ -169,6 +211,8 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
169
211
|
let status = null;
|
|
170
212
|
let openUrl = null;
|
|
171
213
|
let appId = null;
|
|
214
|
+
let rejectionCode;
|
|
215
|
+
const revision = appGrantRevision();
|
|
172
216
|
try {
|
|
173
217
|
const response = await appFetch(probePath, {
|
|
174
218
|
headers: { Accept: "application/json" },
|
|
@@ -177,6 +221,8 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
177
221
|
}, false);
|
|
178
222
|
const body = await response.json().catch(() => null);
|
|
179
223
|
status = readProbedSessionStatus(body);
|
|
224
|
+
const identityError = body;
|
|
225
|
+
rejectionCode = identityError?.error?.code ?? identityError?.appSession?.error?.code;
|
|
180
226
|
const recovery = readRecoveryParams(body);
|
|
181
227
|
appId = recovery.appId;
|
|
182
228
|
openUrl = appId ? buildCoreOpenUrl(recovery.corePublicOrigin, appId, window.location) : null;
|
|
@@ -195,10 +241,30 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
195
241
|
if (cancelled) {
|
|
196
242
|
return;
|
|
197
243
|
}
|
|
244
|
+
if (revision !== appGrantRevision()) {
|
|
245
|
+
initialGrantPresent.current ||= restoreAppGrant();
|
|
246
|
+
void probeAndRecover();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
forgetRejectedAppGrant(rejectionCode, revision);
|
|
250
|
+
const initialLoad = firstProbe.current;
|
|
251
|
+
firstProbe.current = false;
|
|
252
|
+
if (silentError.current === "access_denied") {
|
|
253
|
+
setUi({ kind: "denied" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (initialLoad && !wasActive.current && !initialGrantPresent.current && !silentError.current &&
|
|
257
|
+
detectLaunchMode(window) === "embedded" && (status === "not-present" || status === "expired") && appId && openUrl) {
|
|
258
|
+
const silentUrl = beginSilentSignIn(openUrl, appId);
|
|
259
|
+
if (silentUrl) {
|
|
260
|
+
window.location.replace(silentUrl);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
198
264
|
const action = !status || !appId
|
|
199
265
|
? { kind: "card", card: "unavailable" }
|
|
200
266
|
: decideRecoveryAction({
|
|
201
|
-
status,
|
|
267
|
+
status: silentError.current === "login_required" ? "not-present" : status,
|
|
202
268
|
mode: detectLaunchMode(window),
|
|
203
269
|
appId,
|
|
204
270
|
openUrl,
|
|
@@ -227,6 +293,7 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
227
293
|
if (cancelled)
|
|
228
294
|
return;
|
|
229
295
|
rememberAppGrant(body.accessToken);
|
|
296
|
+
silentError.current = null;
|
|
230
297
|
writeGuard(false);
|
|
231
298
|
await probeAndRecover();
|
|
232
299
|
}).catch(error => { if (!cancelled)
|
|
@@ -253,9 +320,22 @@ export function AppIdentityBridge({ probePath = "/api/auth/identity", appCodePat
|
|
|
253
320
|
window.addEventListener(APP_SESSION_ENDED, recover);
|
|
254
321
|
const url = new URL(window.location.href);
|
|
255
322
|
const code = url.searchParams.get("code")?.trim();
|
|
256
|
-
|
|
323
|
+
const error = url.searchParams.get("error");
|
|
324
|
+
const embedded = detectLaunchMode(window) === "embedded";
|
|
325
|
+
const isSilentError = error === "login_required" || error === "access_denied";
|
|
326
|
+
const accepted = embedded && (code || isSilentError) ? consumeSilentState(url.searchParams.get("state")) : !embedded;
|
|
327
|
+
if (embedded && accepted && isSilentError)
|
|
328
|
+
silentError.current = error;
|
|
329
|
+
if (code || (embedded && isSilentError)) {
|
|
257
330
|
url.searchParams.delete("code");
|
|
331
|
+
if (embedded) {
|
|
332
|
+
url.searchParams.delete("state");
|
|
333
|
+
if (isSilentError)
|
|
334
|
+
url.searchParams.delete("error");
|
|
335
|
+
}
|
|
258
336
|
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
|
337
|
+
}
|
|
338
|
+
if (code && accepted) {
|
|
259
339
|
exchangeRef.current = {
|
|
260
340
|
path: appCodePath,
|
|
261
341
|
response: fetch(appCodePath, {
|
package/package.json
CHANGED