@every-app/sdk 0.1.11 → 0.1.12
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/cloudflare/server/gateway.d.ts.map +1 -1
- package/dist/cloudflare/server/gateway.js +5 -4
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/sessionManager.d.ts +24 -2
- package/dist/core/sessionManager.d.ts.map +1 -1
- package/dist/core/sessionManager.js +191 -27
- package/dist/shared/parseMessagePayload.d.ts +3 -0
- package/dist/shared/parseMessagePayload.d.ts.map +1 -0
- package/dist/shared/parseMessagePayload.js +18 -0
- package/dist/tanstack/EmbeddedAppProvider.d.ts.map +1 -1
- package/dist/tanstack/EmbeddedAppProvider.js +3 -2
- package/dist/tanstack/_internal/useEveryAppSession.d.ts +2 -0
- package/dist/tanstack/_internal/useEveryAppSession.d.ts.map +1 -1
- package/dist/tanstack/_internal/useEveryAppSession.js +8 -2
- package/dist/tanstack/useEveryAppRouter.d.ts.map +1 -1
- package/dist/tanstack/useEveryAppRouter.js +20 -11
- package/package.json +4 -1
- package/src/cloudflare/server/gateway.test.ts +41 -9
- package/src/cloudflare/server/gateway.ts +5 -4
- package/src/core/index.ts +10 -2
- package/src/core/sessionManager.test.ts +143 -0
- package/src/core/sessionManager.ts +265 -30
- package/src/shared/parseMessagePayload.ts +22 -0
- package/src/tanstack/EmbeddedAppProvider.tsx +5 -2
- package/src/tanstack/_internal/useEveryAppSession.test.ts +40 -0
- package/src/tanstack/_internal/useEveryAppSession.tsx +16 -3
- package/src/tanstack/useEveryAppRouter.tsx +21 -14
package/src/core/index.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
1
|
+
export {
|
|
2
|
+
SessionManager,
|
|
3
|
+
isRunningInIframe,
|
|
4
|
+
isRunningInReactNativeWebView,
|
|
5
|
+
detectEnvironment,
|
|
6
|
+
} from "./sessionManager.js";
|
|
7
|
+
export type {
|
|
8
|
+
SessionManagerConfig,
|
|
9
|
+
EmbeddedEnvironment,
|
|
10
|
+
} from "./sessionManager.js";
|
|
3
11
|
|
|
4
12
|
export { authenticatedFetch, getSessionToken } from "./authenticatedFetch.js";
|
|
@@ -47,6 +47,21 @@ describe("SessionManager", () => {
|
|
|
47
47
|
} as MessageEvent);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function createJwtLikeToken(payload: Record<string, unknown>): string {
|
|
51
|
+
return `header.${btoa(JSON.stringify(payload))}.signature`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createBase64UrlJwtLikeToken(
|
|
55
|
+
payload: Record<string, unknown>,
|
|
56
|
+
): string {
|
|
57
|
+
const encoded = btoa(JSON.stringify(payload))
|
|
58
|
+
.replace(/\+/g, "-")
|
|
59
|
+
.replace(/\//g, "_")
|
|
60
|
+
.replace(/=+$/g, "");
|
|
61
|
+
|
|
62
|
+
return `header.${encoded}.signature`;
|
|
63
|
+
}
|
|
64
|
+
|
|
50
65
|
beforeEach(async () => {
|
|
51
66
|
vi.resetModules();
|
|
52
67
|
vi.stubEnv("VITE_GATEWAY_URL", "https://gateway.example.com");
|
|
@@ -552,6 +567,31 @@ describe("SessionManager", () => {
|
|
|
552
567
|
email: "", // Defaults to empty string
|
|
553
568
|
});
|
|
554
569
|
});
|
|
570
|
+
|
|
571
|
+
it("extracts user info from base64url-encoded JWT payload", async () => {
|
|
572
|
+
const manager = new SessionManager({ appId: "test-app" });
|
|
573
|
+
|
|
574
|
+
const fakeToken = createBase64UrlJwtLikeToken({
|
|
575
|
+
sub: "user-123",
|
|
576
|
+
email: "test@example.com",
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const tokenPromise = manager.requestNewToken();
|
|
580
|
+
|
|
581
|
+
simulateTokenResponse({
|
|
582
|
+
token: fakeToken,
|
|
583
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
await tokenPromise;
|
|
587
|
+
|
|
588
|
+
const user = manager.getUser();
|
|
589
|
+
|
|
590
|
+
expect(user).toEqual({
|
|
591
|
+
userId: "user-123",
|
|
592
|
+
email: "test@example.com",
|
|
593
|
+
});
|
|
594
|
+
});
|
|
555
595
|
});
|
|
556
596
|
|
|
557
597
|
describe("default token lifetime", () => {
|
|
@@ -793,4 +833,107 @@ describe("SessionManager", () => {
|
|
|
793
833
|
expect(token).toBe("success-token");
|
|
794
834
|
});
|
|
795
835
|
});
|
|
836
|
+
|
|
837
|
+
describe("react-native-webview token push", () => {
|
|
838
|
+
beforeEach(() => {
|
|
839
|
+
messageHandler = null;
|
|
840
|
+
addEventListenerSpy = vi.fn((event: string, handler: Function) => {
|
|
841
|
+
if (event === "message") {
|
|
842
|
+
messageHandler = handler as (event: MessageEvent) => void;
|
|
843
|
+
}
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
vi.stubGlobal("window", {
|
|
847
|
+
addEventListener: addEventListenerSpy,
|
|
848
|
+
removeEventListener: removeEventListenerSpy,
|
|
849
|
+
ReactNativeWebView: {
|
|
850
|
+
postMessage: vi.fn(),
|
|
851
|
+
},
|
|
852
|
+
parent: {
|
|
853
|
+
postMessage: mockPostMessage,
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("accepts pushed token only when appId and audience match", async () => {
|
|
859
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
860
|
+
const tokenPromise = manager.requestNewToken();
|
|
861
|
+
|
|
862
|
+
const token = createJwtLikeToken({
|
|
863
|
+
sub: "user-1",
|
|
864
|
+
aud: "todo-app",
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
simulateMalformedMessage("react-native", {
|
|
868
|
+
type: "SESSION_TOKEN_UPDATE",
|
|
869
|
+
appId: "todo-app",
|
|
870
|
+
token,
|
|
871
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
await expect(tokenPromise).resolves.toBe(token);
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it("accepts stringified token update payload with null-like origin", async () => {
|
|
878
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
879
|
+
const tokenPromise = manager.requestNewToken();
|
|
880
|
+
|
|
881
|
+
const token = createJwtLikeToken({
|
|
882
|
+
sub: "user-1",
|
|
883
|
+
aud: "todo-app",
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
simulateMalformedMessage(
|
|
887
|
+
"null",
|
|
888
|
+
JSON.stringify({
|
|
889
|
+
type: "SESSION_TOKEN_UPDATE",
|
|
890
|
+
appId: "todo-app",
|
|
891
|
+
token,
|
|
892
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
893
|
+
}),
|
|
894
|
+
);
|
|
895
|
+
|
|
896
|
+
await expect(tokenPromise).resolves.toBe(token);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("ignores malformed JSON token updates", async () => {
|
|
900
|
+
vi.useFakeTimers();
|
|
901
|
+
|
|
902
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
903
|
+
const tokenPromise = manager.requestNewToken();
|
|
904
|
+
|
|
905
|
+
simulateMalformedMessage("react-native", "{not-json");
|
|
906
|
+
|
|
907
|
+
vi.advanceTimersByTime(10001);
|
|
908
|
+
|
|
909
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
910
|
+
"Timed out waiting for token from React Native bridge",
|
|
911
|
+
);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
it("rejects pushed token when audience does not match expected app", async () => {
|
|
915
|
+
vi.useFakeTimers();
|
|
916
|
+
|
|
917
|
+
const manager = new SessionManager({ appId: "todo-app" });
|
|
918
|
+
const tokenPromise = manager.requestNewToken();
|
|
919
|
+
|
|
920
|
+
const wrongAudienceToken = createJwtLikeToken({
|
|
921
|
+
sub: "user-1",
|
|
922
|
+
aud: "chef-app",
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
simulateMalformedMessage("react-native", {
|
|
926
|
+
type: "SESSION_TOKEN_UPDATE",
|
|
927
|
+
appId: "todo-app",
|
|
928
|
+
token: wrongAudienceToken,
|
|
929
|
+
expiresAt: new Date(Date.now() + 60000).toISOString(),
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
vi.advanceTimersByTime(10001);
|
|
933
|
+
|
|
934
|
+
await expect(tokenPromise).rejects.toThrow(
|
|
935
|
+
"Timed out waiting for token from React Native bridge",
|
|
936
|
+
);
|
|
937
|
+
});
|
|
938
|
+
});
|
|
796
939
|
});
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
BYPASS_GATEWAY_LOCAL_ONLY_USER_ID,
|
|
5
5
|
isBypassGatewayLocalOnlyClient,
|
|
6
6
|
} from "../shared/bypassGatewayLocalOnly.js";
|
|
7
|
+
import { parseMessagePayload } from "../shared/parseMessagePayload.js";
|
|
7
8
|
|
|
8
9
|
interface SessionToken {
|
|
9
10
|
token: string;
|
|
@@ -16,6 +17,52 @@ interface TokenResponse {
|
|
|
16
17
|
error?: string;
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
interface TokenUpdateMessage {
|
|
21
|
+
type: "SESSION_TOKEN_UPDATE";
|
|
22
|
+
token?: string;
|
|
23
|
+
expiresAt?: string;
|
|
24
|
+
appId?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isTokenUpdateMessage(data: unknown): data is TokenUpdateMessage {
|
|
28
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (data as { type?: unknown }).type === "SESSION_TOKEN_UPDATE";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
36
|
+
try {
|
|
37
|
+
const parts = token.split(".");
|
|
38
|
+
if (parts.length !== 3) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
43
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
44
|
+
return JSON.parse(atob(padded)) as Record<string, unknown>;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function tokenAudienceMatchesApp(
|
|
51
|
+
payload: Record<string, unknown>,
|
|
52
|
+
appId: string,
|
|
53
|
+
): boolean {
|
|
54
|
+
const aud = payload.aud;
|
|
55
|
+
if (typeof aud === "string") {
|
|
56
|
+
return aud === appId;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (Array.isArray(aud)) {
|
|
60
|
+
return aud.some((value) => value === appId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
export interface SessionManagerConfig {
|
|
20
67
|
appId: string;
|
|
21
68
|
}
|
|
@@ -23,12 +70,25 @@ export interface SessionManagerConfig {
|
|
|
23
70
|
const MESSAGE_TIMEOUT_MS = 5000;
|
|
24
71
|
const TOKEN_EXPIRY_BUFFER_MS = 10000;
|
|
25
72
|
const DEFAULT_TOKEN_LIFETIME_MS = 60000;
|
|
73
|
+
const REACT_NATIVE_TOKEN_WAIT_TIMEOUT_MS = 10000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Environment detection types
|
|
77
|
+
*/
|
|
78
|
+
export type EmbeddedEnvironment =
|
|
79
|
+
| "iframe"
|
|
80
|
+
| "react-native-webview"
|
|
81
|
+
| "standalone";
|
|
26
82
|
|
|
27
83
|
/**
|
|
28
84
|
* Detects whether the current window is running inside an iframe.
|
|
29
85
|
* Returns true if in an iframe, false if running as top-level window.
|
|
30
86
|
*/
|
|
31
87
|
export function isRunningInIframe(): boolean {
|
|
88
|
+
if (typeof window === "undefined") {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
32
92
|
try {
|
|
33
93
|
return window.self !== window.top;
|
|
34
94
|
} catch {
|
|
@@ -38,16 +98,52 @@ export function isRunningInIframe(): boolean {
|
|
|
38
98
|
}
|
|
39
99
|
}
|
|
40
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Detects whether the current window is running inside a React Native WebView.
|
|
103
|
+
* Returns true if window.ReactNativeWebView is available.
|
|
104
|
+
*/
|
|
105
|
+
export function isRunningInReactNativeWebView(): boolean {
|
|
106
|
+
if (typeof window === "undefined") {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
typeof (window as any).ReactNativeWebView?.postMessage === "function" ||
|
|
112
|
+
(window as any).isReactNativeWebView === true
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Detects the current embedded environment.
|
|
118
|
+
* Priority: React Native WebView > iframe > standalone
|
|
119
|
+
*/
|
|
120
|
+
export function detectEnvironment(): EmbeddedEnvironment {
|
|
121
|
+
const isRNWebView = isRunningInReactNativeWebView();
|
|
122
|
+
const isIframe = isRunningInIframe();
|
|
123
|
+
|
|
124
|
+
if (isRNWebView) {
|
|
125
|
+
return "react-native-webview";
|
|
126
|
+
}
|
|
127
|
+
if (isIframe) {
|
|
128
|
+
return "iframe";
|
|
129
|
+
}
|
|
130
|
+
return "standalone";
|
|
131
|
+
}
|
|
132
|
+
|
|
41
133
|
export class SessionManager {
|
|
42
134
|
readonly parentOrigin: string;
|
|
43
135
|
readonly appId: string;
|
|
44
136
|
readonly isInIframe: boolean;
|
|
137
|
+
readonly environment: EmbeddedEnvironment;
|
|
45
138
|
readonly isBypassGatewayLocalOnly: boolean;
|
|
46
|
-
/** @deprecated Use isBypassGatewayLocalOnly instead. */
|
|
47
|
-
readonly isDemoModeLocalOnly: boolean;
|
|
48
139
|
|
|
49
140
|
private token: SessionToken | null = null;
|
|
50
141
|
private refreshPromise: Promise<string> | null = null;
|
|
142
|
+
private tokenWaiters: Array<{
|
|
143
|
+
resolve: (token: string) => void;
|
|
144
|
+
reject: (error: Error) => void;
|
|
145
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
146
|
+
}> = [];
|
|
51
147
|
|
|
52
148
|
constructor(config: SessionManagerConfig) {
|
|
53
149
|
if (!config.appId) {
|
|
@@ -55,7 +151,6 @@ export class SessionManager {
|
|
|
55
151
|
}
|
|
56
152
|
|
|
57
153
|
this.isBypassGatewayLocalOnly = isBypassGatewayLocalOnlyClient();
|
|
58
|
-
this.isDemoModeLocalOnly = this.isBypassGatewayLocalOnly;
|
|
59
154
|
|
|
60
155
|
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL;
|
|
61
156
|
if (!this.isBypassGatewayLocalOnly) {
|
|
@@ -76,8 +171,13 @@ export class SessionManager {
|
|
|
76
171
|
this.parentOrigin = this.isBypassGatewayLocalOnly
|
|
77
172
|
? window.location.origin
|
|
78
173
|
: gatewayUrl;
|
|
174
|
+
this.environment = detectEnvironment();
|
|
79
175
|
this.isInIframe = isRunningInIframe();
|
|
80
176
|
|
|
177
|
+
if (this.environment === "react-native-webview") {
|
|
178
|
+
this.setupReactNativeTokenListener();
|
|
179
|
+
}
|
|
180
|
+
|
|
81
181
|
if (this.isBypassGatewayLocalOnly) {
|
|
82
182
|
this.token = {
|
|
83
183
|
token: BYPASS_GATEWAY_LOCAL_ONLY_TOKEN,
|
|
@@ -86,6 +186,45 @@ export class SessionManager {
|
|
|
86
186
|
}
|
|
87
187
|
}
|
|
88
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Check if running in an embedded environment (iframe or React Native WebView)
|
|
191
|
+
*/
|
|
192
|
+
isEmbedded(): boolean {
|
|
193
|
+
return this.environment !== "standalone";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
isTrustedHostMessage(event: MessageEvent): boolean {
|
|
197
|
+
if (this.environment === "react-native-webview") {
|
|
198
|
+
const origin = (event as MessageEvent & { origin?: string | null })
|
|
199
|
+
.origin;
|
|
200
|
+
return (
|
|
201
|
+
origin === "react-native" ||
|
|
202
|
+
origin === "null" ||
|
|
203
|
+
origin === "" ||
|
|
204
|
+
origin == null
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return event.origin === this.parentOrigin;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
postToHost(message: object): void {
|
|
212
|
+
if (this.environment === "react-native-webview") {
|
|
213
|
+
const postMessage = (window as any).ReactNativeWebView?.postMessage;
|
|
214
|
+
if (typeof postMessage !== "function") {
|
|
215
|
+
throw new Error("React Native WebView bridge is unavailable");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
postMessage.call(
|
|
219
|
+
(window as any).ReactNativeWebView,
|
|
220
|
+
JSON.stringify(message),
|
|
221
|
+
);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
window.parent.postMessage(message, this.parentOrigin);
|
|
226
|
+
}
|
|
227
|
+
|
|
89
228
|
private isTokenExpiringSoon(
|
|
90
229
|
bufferMs: number = TOKEN_EXPIRY_BUFFER_MS,
|
|
91
230
|
): boolean {
|
|
@@ -105,19 +244,17 @@ export class SessionManager {
|
|
|
105
244
|
};
|
|
106
245
|
|
|
107
246
|
const handler = (event: MessageEvent) => {
|
|
108
|
-
// Security:
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
event.data.requestId === requestId
|
|
115
|
-
) {
|
|
247
|
+
// Security: validate message origin based on environment
|
|
248
|
+
if (!this.isTrustedHostMessage(event)) return;
|
|
249
|
+
const data = parseMessagePayload(event.data);
|
|
250
|
+
if (!data) return;
|
|
251
|
+
|
|
252
|
+
if (data.type === responseType && data.requestId === requestId) {
|
|
116
253
|
cleanup();
|
|
117
|
-
if (
|
|
118
|
-
reject(new Error(
|
|
254
|
+
if (typeof data.error === "string" && data.error) {
|
|
255
|
+
reject(new Error(data.error));
|
|
119
256
|
} else {
|
|
120
|
-
resolve(
|
|
257
|
+
resolve(data as T);
|
|
121
258
|
}
|
|
122
259
|
}
|
|
123
260
|
};
|
|
@@ -128,7 +265,100 @@ export class SessionManager {
|
|
|
128
265
|
}, MESSAGE_TIMEOUT_MS);
|
|
129
266
|
|
|
130
267
|
window.addEventListener("message", handler);
|
|
131
|
-
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
this.postToHost(request);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
cleanup();
|
|
273
|
+
reject(
|
|
274
|
+
error instanceof Error
|
|
275
|
+
? error
|
|
276
|
+
: new Error("Failed to post message to host"),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private setupReactNativeTokenListener(): void {
|
|
283
|
+
window.addEventListener("message", (event: MessageEvent) => {
|
|
284
|
+
if (!this.isTrustedHostMessage(event)) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const data = parseMessagePayload(event.data);
|
|
289
|
+
if (!data) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!isTokenUpdateMessage(data)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const message = data;
|
|
298
|
+
|
|
299
|
+
if (!message.token || typeof message.token !== "string") {
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (typeof message.appId !== "string" || message.appId !== this.appId) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (
|
|
308
|
+
message.expiresAt !== undefined &&
|
|
309
|
+
typeof message.expiresAt !== "string"
|
|
310
|
+
) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const payload = decodeJwtPayload(message.token);
|
|
315
|
+
if (!payload || !tokenAudienceMatchesApp(payload, this.appId)) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Native controls token minting and pushes updates into the embedded app.
|
|
320
|
+
// We only consume pushed tokens in React Native WebView mode.
|
|
321
|
+
let expiresAt = Date.now() + DEFAULT_TOKEN_LIFETIME_MS;
|
|
322
|
+
if (message.expiresAt) {
|
|
323
|
+
const parsed = new Date(message.expiresAt).getTime();
|
|
324
|
+
if (!Number.isNaN(parsed)) {
|
|
325
|
+
expiresAt = parsed;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.token = {
|
|
330
|
+
token: message.token,
|
|
331
|
+
expiresAt,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
if (this.tokenWaiters.length > 0) {
|
|
335
|
+
const waiters = this.tokenWaiters;
|
|
336
|
+
this.tokenWaiters = [];
|
|
337
|
+
|
|
338
|
+
for (const waiter of waiters) {
|
|
339
|
+
clearTimeout(waiter.timeout);
|
|
340
|
+
waiter.resolve(message.token);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private waitForReactNativeTokenPush(): Promise<string> {
|
|
347
|
+
if (this.token && !this.isTokenExpiringSoon()) {
|
|
348
|
+
return Promise.resolve(this.token.token);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return new Promise((resolve, reject) => {
|
|
352
|
+
const timeout = setTimeout(() => {
|
|
353
|
+
this.tokenWaiters = this.tokenWaiters.filter(
|
|
354
|
+
(w) => w.timeout !== timeout,
|
|
355
|
+
);
|
|
356
|
+
reject(
|
|
357
|
+
new Error("Timed out waiting for token from React Native bridge"),
|
|
358
|
+
);
|
|
359
|
+
}, REACT_NATIVE_TOKEN_WAIT_TIMEOUT_MS);
|
|
360
|
+
|
|
361
|
+
this.tokenWaiters.push({ resolve, reject, timeout });
|
|
132
362
|
});
|
|
133
363
|
}
|
|
134
364
|
|
|
@@ -145,6 +375,16 @@ export class SessionManager {
|
|
|
145
375
|
return this.refreshPromise;
|
|
146
376
|
}
|
|
147
377
|
|
|
378
|
+
if (this.environment === "react-native-webview") {
|
|
379
|
+
this.refreshPromise = this.waitForReactNativeTokenPush();
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
return await this.refreshPromise;
|
|
383
|
+
} finally {
|
|
384
|
+
this.refreshPromise = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
148
388
|
this.refreshPromise = (async () => {
|
|
149
389
|
const requestId = crypto.randomUUID();
|
|
150
390
|
|
|
@@ -235,23 +475,18 @@ export class SessionManager {
|
|
|
235
475
|
return null;
|
|
236
476
|
}
|
|
237
477
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const payload = JSON.parse(atob(parts[1]));
|
|
245
|
-
if (!payload.sub) {
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
478
|
+
const payload = decodeJwtPayload(this.token.token);
|
|
479
|
+
if (!payload) {
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
248
482
|
|
|
249
|
-
|
|
250
|
-
userId: payload.sub,
|
|
251
|
-
email: payload.email ?? "",
|
|
252
|
-
};
|
|
253
|
-
} catch {
|
|
483
|
+
if (typeof payload.sub !== "string") {
|
|
254
484
|
return null;
|
|
255
485
|
}
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
userId: payload.sub,
|
|
489
|
+
email: typeof payload.email === "string" ? payload.email : "",
|
|
490
|
+
};
|
|
256
491
|
}
|
|
257
492
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type MessagePayload = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export function parseMessagePayload(data: unknown): MessagePayload | null {
|
|
4
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
5
|
+
return data as MessagePayload;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
if (typeof data !== "string") {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(data);
|
|
14
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
15
|
+
return parsed as MessagePayload;
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
@@ -30,8 +30,11 @@ export function EmbeddedAppProvider({
|
|
|
30
30
|
|
|
31
31
|
if (!sessionManager) return null;
|
|
32
32
|
|
|
33
|
-
// Check if the app is running outside of the Gateway iframe
|
|
34
|
-
if (
|
|
33
|
+
// Check if the app is running outside of the Gateway (iframe or React Native WebView)
|
|
34
|
+
if (
|
|
35
|
+
!sessionManager.isEmbedded() &&
|
|
36
|
+
!sessionManager.isBypassGatewayLocalOnly
|
|
37
|
+
) {
|
|
35
38
|
return (
|
|
36
39
|
<GatewayRequiredError
|
|
37
40
|
gatewayOrigin={sessionManager.parentOrigin}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldBootstrapSession } from "./useEveryAppSession";
|
|
3
|
+
|
|
4
|
+
describe("shouldBootstrapSession", () => {
|
|
5
|
+
it("returns false for standalone apps without bypass", () => {
|
|
6
|
+
expect(
|
|
7
|
+
shouldBootstrapSession({
|
|
8
|
+
isEmbedded: () => false,
|
|
9
|
+
isBypassGatewayLocalOnly: false,
|
|
10
|
+
}),
|
|
11
|
+
).toBe(false);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns true for embedded apps", () => {
|
|
15
|
+
expect(
|
|
16
|
+
shouldBootstrapSession({
|
|
17
|
+
isEmbedded: () => true,
|
|
18
|
+
isBypassGatewayLocalOnly: false,
|
|
19
|
+
}),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns true for local bypass mode", () => {
|
|
24
|
+
expect(
|
|
25
|
+
shouldBootstrapSession({
|
|
26
|
+
isEmbedded: () => false,
|
|
27
|
+
isBypassGatewayLocalOnly: true,
|
|
28
|
+
}),
|
|
29
|
+
).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("does not depend on iframe detection for RN webviews", () => {
|
|
33
|
+
expect(
|
|
34
|
+
shouldBootstrapSession({
|
|
35
|
+
isEmbedded: () => true,
|
|
36
|
+
isBypassGatewayLocalOnly: false,
|
|
37
|
+
}),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -9,6 +9,20 @@ interface UseEveryAppSessionParams {
|
|
|
9
9
|
sessionManagerConfig: SessionManagerConfig;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
type SessionBootstrapGate = Pick<
|
|
13
|
+
SessionManager,
|
|
14
|
+
"isEmbedded" | "isBypassGatewayLocalOnly"
|
|
15
|
+
>;
|
|
16
|
+
|
|
17
|
+
export function shouldBootstrapSession(
|
|
18
|
+
sessionManager: SessionBootstrapGate,
|
|
19
|
+
): boolean {
|
|
20
|
+
// Bootstrapping should happen whenever the app is hosted by a trusted container.
|
|
21
|
+
// This intentionally keys off embedded environment semantics (iframe OR RN WebView),
|
|
22
|
+
// not iframe-only detection, so RN can initialize auth from pushed tokens.
|
|
23
|
+
return sessionManager.isEmbedded() || sessionManager.isBypassGatewayLocalOnly;
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export function useEveryAppSession({
|
|
13
27
|
sessionManagerConfig,
|
|
14
28
|
}: UseEveryAppSessionParams) {
|
|
@@ -28,9 +42,8 @@ export function useEveryAppSession({
|
|
|
28
42
|
|
|
29
43
|
useEffect(() => {
|
|
30
44
|
if (!sessionManager) return;
|
|
31
|
-
// Skip token
|
|
32
|
-
if (!sessionManager
|
|
33
|
-
return;
|
|
45
|
+
// Skip token bootstrap when not embedded (unless in demo mode) - the app will show GatewayRequiredError instead
|
|
46
|
+
if (!shouldBootstrapSession(sessionManager)) return;
|
|
34
47
|
|
|
35
48
|
const interval = setInterval(() => {
|
|
36
49
|
setSessionTokenState(sessionManager.getTokenState());
|