@every-app/sdk 0.0.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/package.json +37 -0
- package/src/client/EmbeddedAppProvider.tsx +77 -0
- package/src/client/_internal/useEveryAppRouter.tsx +75 -0
- package/src/client/_internal/useEveryAppSession.tsx +53 -0
- package/src/client/authenticatedFetch.ts +45 -0
- package/src/client/index.ts +9 -0
- package/src/client/lazyInitForWorkers.ts +67 -0
- package/src/client/session-manager.ts +289 -0
- package/src/client/useSessionTokenClientMiddleware.ts +31 -0
- package/src/env.d.ts +11 -0
- package/src/server/auth-config.ts +10 -0
- package/src/server/authenticateRequest.ts +104 -0
- package/src/server/getLocalD1Url.ts +66 -0
- package/src/server/index.ts +4 -0
- package/src/server/types.ts +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@every-app/sdk",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/client/index.ts",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./client": "./src/client/index.ts",
|
|
8
|
+
"./server": "./src/server/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"types:check": "tsc --noEmit",
|
|
15
|
+
"format:check": "prettier --check .",
|
|
16
|
+
"format:write": "prettier . --write"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@tanstack/react-router": "^1.0.0",
|
|
20
|
+
"@tanstack/react-start": "^1.0.0",
|
|
21
|
+
"jose": "^6.0.0",
|
|
22
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@cloudflare/workers-types": "^4.20251014.0",
|
|
26
|
+
"@tanstack/react-router": "^1.136.3",
|
|
27
|
+
"@tanstack/react-start": "^1.136.3",
|
|
28
|
+
"@types/node": "^22.18.13",
|
|
29
|
+
"@types/react": "^19.0.8",
|
|
30
|
+
"jose": "^6.0.12",
|
|
31
|
+
"jsonc-parser": "^3.3.1",
|
|
32
|
+
"prettier": "^3.6.2",
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"typescript": "^5.9.3",
|
|
35
|
+
"vite": "^7.1.2"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React, { createContext, useContext, useMemo } from "react";
|
|
2
|
+
import { SessionManager, SessionManagerConfig } from "./session-manager";
|
|
3
|
+
import { useEveryAppSession } from "./_internal/useEveryAppSession";
|
|
4
|
+
import { useEveryAppRouter } from "./_internal/useEveryAppRouter";
|
|
5
|
+
|
|
6
|
+
interface EmbeddedProviderConfig extends SessionManagerConfig {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface EmbeddedAppContextValue {
|
|
11
|
+
sessionManager: SessionManager;
|
|
12
|
+
isAuthenticated: boolean;
|
|
13
|
+
sessionTokenState: ReturnType<SessionManager["getTokenState"]>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EmbeddedAppContext = createContext<EmbeddedAppContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
export function EmbeddedAppProvider({
|
|
19
|
+
children,
|
|
20
|
+
...config
|
|
21
|
+
}: EmbeddedProviderConfig) {
|
|
22
|
+
const { sessionManager, sessionTokenState } = useEveryAppSession({
|
|
23
|
+
sessionManagerConfig: config,
|
|
24
|
+
});
|
|
25
|
+
useEveryAppRouter({ sessionManager });
|
|
26
|
+
|
|
27
|
+
if (!sessionManager) return null;
|
|
28
|
+
|
|
29
|
+
const value: EmbeddedAppContextValue = {
|
|
30
|
+
sessionManager,
|
|
31
|
+
isAuthenticated: sessionTokenState.status === "VALID",
|
|
32
|
+
sessionTokenState,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<EmbeddedAppContext.Provider value={value}>
|
|
37
|
+
{children}
|
|
38
|
+
</EmbeddedAppContext.Provider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hook to get the current authenticated user.
|
|
44
|
+
* Returns the user's ID and email extracted from the JWT token,
|
|
45
|
+
* or null if not authenticated.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```tsx
|
|
49
|
+
* function MyComponent() {
|
|
50
|
+
* const user = useCurrentUser();
|
|
51
|
+
*
|
|
52
|
+
* if (!user) {
|
|
53
|
+
* return <div>Not authenticated</div>;
|
|
54
|
+
* }
|
|
55
|
+
*
|
|
56
|
+
* return <div>Welcome, {user.email}</div>;
|
|
57
|
+
* }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export function useCurrentUser(): { userId: string; email: string } | null {
|
|
61
|
+
const context = useContext(EmbeddedAppContext);
|
|
62
|
+
|
|
63
|
+
if (!context) {
|
|
64
|
+
throw new Error("useCurrentUser must be used within an EmbeddedAppProvider");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const { sessionManager, sessionTokenState } = context;
|
|
68
|
+
|
|
69
|
+
return useMemo(() => {
|
|
70
|
+
// Only return user if we have a valid token
|
|
71
|
+
if (sessionTokenState.status !== "VALID") {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return sessionManager.getUser();
|
|
76
|
+
}, [sessionManager, sessionTokenState]);
|
|
77
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { SessionManager } from "../session-manager";
|
|
3
|
+
import { useRouter } from "@tanstack/react-router";
|
|
4
|
+
|
|
5
|
+
interface UseEveryAppRouterParams {
|
|
6
|
+
sessionManager: SessionManager | null;
|
|
7
|
+
}
|
|
8
|
+
export function useEveryAppRouter({ sessionManager }: UseEveryAppRouterParams) {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
// Route synchronization effect
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
if (!sessionManager) return;
|
|
13
|
+
// Listen for route sync messages from parent
|
|
14
|
+
const handleMessage = (event: MessageEvent) => {
|
|
15
|
+
if (event.origin !== sessionManager.getParentOrigin()) return;
|
|
16
|
+
|
|
17
|
+
if (
|
|
18
|
+
event.data.type === "ROUTE_CHANGE" &&
|
|
19
|
+
event.data.direction === "parent-to-child"
|
|
20
|
+
) {
|
|
21
|
+
const targetRoute = event.data.route;
|
|
22
|
+
const currentRoute = window.location.pathname;
|
|
23
|
+
|
|
24
|
+
// Only navigate if the route is different from current location
|
|
25
|
+
if (targetRoute && targetRoute !== currentRoute) {
|
|
26
|
+
router.navigate({ to: targetRoute });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
window.addEventListener("message", handleMessage);
|
|
32
|
+
|
|
33
|
+
// Simplified route change detection with 2 reliable methods
|
|
34
|
+
let lastReportedPath = window.location.pathname;
|
|
35
|
+
|
|
36
|
+
const handleRouteChange = () => {
|
|
37
|
+
const currentPath = window.location.pathname;
|
|
38
|
+
|
|
39
|
+
// Only report if the path actually changed
|
|
40
|
+
if (currentPath === lastReportedPath) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
lastReportedPath = currentPath;
|
|
45
|
+
|
|
46
|
+
if (window.parent !== window) {
|
|
47
|
+
window.parent.postMessage(
|
|
48
|
+
{
|
|
49
|
+
type: "ROUTE_CHANGE",
|
|
50
|
+
route: currentPath,
|
|
51
|
+
appId: sessionManager.getAppId(),
|
|
52
|
+
direction: "child-to-parent",
|
|
53
|
+
},
|
|
54
|
+
sessionManager.getParentOrigin(),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
// Listen to popstate for browser back/forward
|
|
59
|
+
window.addEventListener("popstate", handleRouteChange);
|
|
60
|
+
|
|
61
|
+
// Polling to detect route changes (catches router navigation)
|
|
62
|
+
const pollInterval = setInterval(() => {
|
|
63
|
+
const currentPath = window.location.pathname;
|
|
64
|
+
if (currentPath !== lastReportedPath) {
|
|
65
|
+
handleRouteChange();
|
|
66
|
+
}
|
|
67
|
+
}, 100);
|
|
68
|
+
|
|
69
|
+
return () => {
|
|
70
|
+
window.removeEventListener("message", handleMessage);
|
|
71
|
+
window.removeEventListener("popstate", handleRouteChange);
|
|
72
|
+
clearInterval(pollInterval);
|
|
73
|
+
};
|
|
74
|
+
}, [sessionManager]);
|
|
75
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { SessionManager, SessionManagerConfig } from "../session-manager";
|
|
3
|
+
|
|
4
|
+
interface UseEveryAppSessionParams {
|
|
5
|
+
sessionManagerConfig: SessionManagerConfig;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function useEveryAppSession({
|
|
9
|
+
sessionManagerConfig,
|
|
10
|
+
}: UseEveryAppSessionParams) {
|
|
11
|
+
const sessionManagerRef = useRef<SessionManager>(null);
|
|
12
|
+
const [sessionTokenState, setSessionTokenState] = useState<
|
|
13
|
+
ReturnType<SessionManager["getTokenState"]>
|
|
14
|
+
>({
|
|
15
|
+
status: "NO_TOKEN",
|
|
16
|
+
token: null,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (!sessionManagerRef.current && typeof document !== "undefined") {
|
|
20
|
+
sessionManagerRef.current = new SessionManager(sessionManagerConfig);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const sessionManager = sessionManagerRef.current;
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!sessionManager) return;
|
|
27
|
+
const interval = setInterval(() => {
|
|
28
|
+
setSessionTokenState(sessionManager.getTokenState());
|
|
29
|
+
}, 1000);
|
|
30
|
+
|
|
31
|
+
const unsubscribe = sessionManager.onDebugEvent(() => {
|
|
32
|
+
setSessionTokenState(sessionManager.getTokenState());
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
sessionManager.getToken().catch((err) => {
|
|
36
|
+
console.error("[EmbeddedProvider] Initial token request failed:", err);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return () => {
|
|
40
|
+
clearInterval(interval);
|
|
41
|
+
unsubscribe();
|
|
42
|
+
};
|
|
43
|
+
}, [sessionManager]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!sessionManager) return;
|
|
47
|
+
|
|
48
|
+
// Make sessionManager globally accessible for middleware
|
|
49
|
+
(window as any).__embeddedSessionManager = sessionManager;
|
|
50
|
+
}, [sessionManager]);
|
|
51
|
+
|
|
52
|
+
return { sessionManager, sessionTokenState };
|
|
53
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
interface SessionManager {
|
|
2
|
+
getToken(): Promise<string>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
interface WindowWithSessionManager extends Window {
|
|
6
|
+
__embeddedSessionManager?: SessionManager;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gets the current session token from the embedded session manager
|
|
11
|
+
*/
|
|
12
|
+
export async function getSessionToken(): Promise<string> {
|
|
13
|
+
const windowWithSession = window as WindowWithSessionManager;
|
|
14
|
+
const sessionManager = windowWithSession.__embeddedSessionManager;
|
|
15
|
+
|
|
16
|
+
if (!sessionManager) {
|
|
17
|
+
throw new Error("Session manager not available");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const token = await sessionManager.getToken();
|
|
21
|
+
|
|
22
|
+
if (!token) {
|
|
23
|
+
throw new Error("No token available");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return token;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Performs a fetch request with the authorization header automatically added
|
|
31
|
+
*/
|
|
32
|
+
export async function authenticatedFetch(
|
|
33
|
+
input: RequestInfo | URL,
|
|
34
|
+
init?: RequestInit,
|
|
35
|
+
): Promise<Response> {
|
|
36
|
+
const token = await getSessionToken();
|
|
37
|
+
|
|
38
|
+
const headers = new Headers(init?.headers);
|
|
39
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
40
|
+
|
|
41
|
+
return fetch(input, {
|
|
42
|
+
...init,
|
|
43
|
+
headers,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { SessionManager } from "./session-manager";
|
|
2
|
+
export type { SessionManagerConfig } from "./session-manager";
|
|
3
|
+
export { useSessionTokenClientMiddleware } from "./useSessionTokenClientMiddleware";
|
|
4
|
+
|
|
5
|
+
export { EmbeddedAppProvider, useCurrentUser } from "./EmbeddedAppProvider";
|
|
6
|
+
|
|
7
|
+
export { lazyInitForWorkers } from "./lazyInitForWorkers";
|
|
8
|
+
|
|
9
|
+
export { authenticatedFetch, getSessionToken } from "./authenticatedFetch";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps a factory function in a Proxy to defer initialization until first access.
|
|
3
|
+
* This prevents async operations (Like creating Tanstack DB Collections) from running in Cloudflare Workers' global scope.
|
|
4
|
+
*
|
|
5
|
+
* @param factory - A function that creates and returns the resource.
|
|
6
|
+
* Must be a callback to defer execution; passing the value directly
|
|
7
|
+
* would evaluate it at module load time, triggering the Cloudflare error.
|
|
8
|
+
* @returns A Proxy that lazily initializes the resource on first property access
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* export const myCollection = lazyInitForWorkers(() =>
|
|
13
|
+
* createCollection(queryCollectionOptions({
|
|
14
|
+
* queryKey: ["myData"],
|
|
15
|
+
* queryFn: async () => fetchData(),
|
|
16
|
+
* // ... other options
|
|
17
|
+
* }))
|
|
18
|
+
* );
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function lazyInitForWorkers<T extends object>(factory: () => T): T {
|
|
22
|
+
// Closure: This variable is captured by getInstance() and the Proxy traps below.
|
|
23
|
+
// It remains in memory as long as the returned Proxy is referenced, enabling singleton behavior.
|
|
24
|
+
let instance: T | null = null;
|
|
25
|
+
|
|
26
|
+
function getInstance() {
|
|
27
|
+
if (!instance) {
|
|
28
|
+
instance = factory();
|
|
29
|
+
}
|
|
30
|
+
return instance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return new Proxy({} as T, {
|
|
34
|
+
get(_, prop) {
|
|
35
|
+
const inst = getInstance();
|
|
36
|
+
const value = inst[prop as keyof T];
|
|
37
|
+
// Bind methods to the instance to preserve `this` context
|
|
38
|
+
return typeof value === "function" ? value.bind(inst) : value;
|
|
39
|
+
},
|
|
40
|
+
set(_, prop, value) {
|
|
41
|
+
const inst = getInstance();
|
|
42
|
+
(inst as any)[prop] = value;
|
|
43
|
+
return true;
|
|
44
|
+
},
|
|
45
|
+
deleteProperty(_, prop) {
|
|
46
|
+
const inst = getInstance();
|
|
47
|
+
delete (inst as any)[prop];
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
has(_, prop) {
|
|
51
|
+
const inst = getInstance();
|
|
52
|
+
return prop in inst;
|
|
53
|
+
},
|
|
54
|
+
ownKeys(_) {
|
|
55
|
+
const inst = getInstance();
|
|
56
|
+
return Reflect.ownKeys(inst);
|
|
57
|
+
},
|
|
58
|
+
getOwnPropertyDescriptor(_, prop) {
|
|
59
|
+
const inst = getInstance();
|
|
60
|
+
return Reflect.getOwnPropertyDescriptor(inst, prop);
|
|
61
|
+
},
|
|
62
|
+
getPrototypeOf(_) {
|
|
63
|
+
const inst = getInstance();
|
|
64
|
+
return Reflect.getPrototypeOf(inst);
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
interface SessionToken {
|
|
2
|
+
token: string;
|
|
3
|
+
expiresAt: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SessionManagerConfig {
|
|
7
|
+
appId: string;
|
|
8
|
+
debug?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SessionManager {
|
|
12
|
+
private token: SessionToken | null = null;
|
|
13
|
+
private refreshPromise: Promise<string> | null = null;
|
|
14
|
+
private parentOrigin: string;
|
|
15
|
+
private appId: string;
|
|
16
|
+
private messageTimeout: number;
|
|
17
|
+
private debug: boolean;
|
|
18
|
+
private onError?: (error: Error) => void;
|
|
19
|
+
private pendingRequests = new Map<
|
|
20
|
+
string,
|
|
21
|
+
{
|
|
22
|
+
resolve: (token: string) => void;
|
|
23
|
+
reject: (error: Error) => void;
|
|
24
|
+
timeout: NodeJS.Timeout;
|
|
25
|
+
}
|
|
26
|
+
>();
|
|
27
|
+
|
|
28
|
+
constructor(config: SessionManagerConfig) {
|
|
29
|
+
this.parentOrigin = import.meta.env.VITE_GATEWAY_URL;
|
|
30
|
+
this.appId = config.appId || this.detectAppId();
|
|
31
|
+
this.messageTimeout = 5000;
|
|
32
|
+
this.debug = config.debug ?? false;
|
|
33
|
+
|
|
34
|
+
if (!this.parentOrigin) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
"[SessionManager] Set the Parent Origin by specifying the VITE_GATEWAY_URL env var.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
new URL(this.parentOrigin);
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`[SessionManager] Invalid parent origin URL: ${this.parentOrigin}`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.setupMessageListener();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private detectAppId(): string {
|
|
52
|
+
if (typeof window === "undefined") return "";
|
|
53
|
+
|
|
54
|
+
const url = new URL(window.location.href);
|
|
55
|
+
return (
|
|
56
|
+
url.searchParams.get("appId") ||
|
|
57
|
+
url.hostname.split(".")[0] ||
|
|
58
|
+
"embedded-app"
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private log(message: string, data?: unknown) {
|
|
63
|
+
if (this.debug) {
|
|
64
|
+
console.log(`[SessionManager - Logger] ${message}`, data);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private setupMessageListener() {
|
|
69
|
+
if (typeof window === "undefined") return;
|
|
70
|
+
|
|
71
|
+
window.addEventListener("message", (event) => {
|
|
72
|
+
if (event.origin !== this.parentOrigin) {
|
|
73
|
+
this.log("Message rejected due to origin mismatch", {
|
|
74
|
+
expected: this.parentOrigin,
|
|
75
|
+
received: event.origin,
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.log("Accepted message from parent", event.data);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private isTokenExpired(): boolean {
|
|
85
|
+
if (!this.token) return true;
|
|
86
|
+
return Date.now() >= this.token.expiresAt;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private isTokenExpiringSoon(bufferMs: number = 10000): boolean {
|
|
90
|
+
if (!this.token) return true;
|
|
91
|
+
return Date.now() >= this.token.expiresAt - bufferMs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async requestNewToken(): Promise<string> {
|
|
95
|
+
if (this.refreshPromise) {
|
|
96
|
+
return this.refreshPromise;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.refreshPromise = new Promise((resolve, reject) => {
|
|
100
|
+
const requestId = Date.now().toString();
|
|
101
|
+
|
|
102
|
+
const timeout = setTimeout(() => {
|
|
103
|
+
this.pendingRequests.delete(requestId);
|
|
104
|
+
this.log(`Token request #${requestId} timed out`);
|
|
105
|
+
const error = new Error(
|
|
106
|
+
"Token refresh timeout - parent did not respond",
|
|
107
|
+
);
|
|
108
|
+
this.onError?.(error);
|
|
109
|
+
reject(error);
|
|
110
|
+
}, this.messageTimeout);
|
|
111
|
+
|
|
112
|
+
this.pendingRequests.set(requestId, { resolve, reject, timeout });
|
|
113
|
+
|
|
114
|
+
const messageHandler = (event: MessageEvent) => {
|
|
115
|
+
if (event.origin !== this.parentOrigin) {
|
|
116
|
+
this.log("Ignoring message from unexpected origin", {
|
|
117
|
+
expected: this.parentOrigin,
|
|
118
|
+
received: event.origin,
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.log(`Received message for request #${requestId}`, event.data);
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
event.data.type === "SESSION_TOKEN_RESPONSE" &&
|
|
127
|
+
event.data.requestId === requestId
|
|
128
|
+
) {
|
|
129
|
+
clearTimeout(timeout);
|
|
130
|
+
window.removeEventListener("message", messageHandler);
|
|
131
|
+
|
|
132
|
+
if (event.data.error) {
|
|
133
|
+
this.log(`Token request #${requestId} failed`, {
|
|
134
|
+
error: event.data.error,
|
|
135
|
+
});
|
|
136
|
+
const error = new Error(event.data.error);
|
|
137
|
+
this.onError?.(error);
|
|
138
|
+
reject(error);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!event.data.token) {
|
|
143
|
+
this.log(
|
|
144
|
+
`Token request #${requestId} failed - no token in response`,
|
|
145
|
+
);
|
|
146
|
+
const error = new Error("No token in response");
|
|
147
|
+
this.onError?.(error);
|
|
148
|
+
reject(error);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.token = {
|
|
153
|
+
token: event.data.token,
|
|
154
|
+
expiresAt: event.data.expiresAt
|
|
155
|
+
? new Date(event.data.expiresAt).getTime()
|
|
156
|
+
: Date.now() + 60000,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
this.log(`Token #${requestId} received successfully`, {
|
|
160
|
+
expiresAt: new Date(this.token.expiresAt).toISOString(),
|
|
161
|
+
});
|
|
162
|
+
resolve(this.token.token);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
window.addEventListener("message", messageHandler);
|
|
167
|
+
|
|
168
|
+
this.log(
|
|
169
|
+
`Requesting new session token #${requestId} for app "${this.appId}"`,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// Fire-and-forget postMessage to the parent
|
|
173
|
+
try {
|
|
174
|
+
window.parent.postMessage(
|
|
175
|
+
{
|
|
176
|
+
type: "SESSION_TOKEN_REQUEST",
|
|
177
|
+
requestId: requestId,
|
|
178
|
+
appId: this.appId,
|
|
179
|
+
},
|
|
180
|
+
this.parentOrigin,
|
|
181
|
+
);
|
|
182
|
+
this.log(`Message sent for token request #${requestId}`, {
|
|
183
|
+
targetOrigin: this.parentOrigin,
|
|
184
|
+
});
|
|
185
|
+
} catch (e) {
|
|
186
|
+
this.log(`postMessage failed for token request #${requestId}`, e);
|
|
187
|
+
// We don't reject the promise here because the timeout will handle it
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
return await this.refreshPromise;
|
|
193
|
+
} finally {
|
|
194
|
+
this.refreshPromise = null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async getToken(): Promise<string> {
|
|
199
|
+
// If token is expired or expiring soon (within 10 seconds), get a new one
|
|
200
|
+
if (this.isTokenExpiringSoon() || !this.token) {
|
|
201
|
+
this.log("Token expired or expiring soon, requesting new token", {
|
|
202
|
+
hasToken: !!this.token,
|
|
203
|
+
expiresAt: this.token
|
|
204
|
+
? new Date(this.token.expiresAt).toISOString()
|
|
205
|
+
: "N/A",
|
|
206
|
+
timeUntilExpiry: this.token ? this.token.expiresAt - Date.now() : "N/A",
|
|
207
|
+
});
|
|
208
|
+
return this.requestNewToken();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return this.token.token;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
getParentOrigin(): string {
|
|
215
|
+
return this.parentOrigin;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getAppId(): string {
|
|
219
|
+
return this.appId;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getTokenState(): {
|
|
223
|
+
status: "NO_TOKEN" | "VALID" | "EXPIRED" | "REFRESHING";
|
|
224
|
+
token: string | null;
|
|
225
|
+
} {
|
|
226
|
+
if (this.refreshPromise) {
|
|
227
|
+
return { status: "REFRESHING", token: null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!this.token) {
|
|
231
|
+
return { status: "NO_TOKEN", token: null };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (this.isTokenExpired()) {
|
|
235
|
+
return { status: "EXPIRED", token: this.token.token };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { status: "VALID", token: this.token.token };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
onDebugEvent(callback: () => void): () => void {
|
|
242
|
+
// For now, we'll create a simple event system
|
|
243
|
+
// In a more complete implementation, you might want to emit events at various points
|
|
244
|
+
const intervalId = setInterval(() => {
|
|
245
|
+
// This will trigger the callback periodically to update the UI
|
|
246
|
+
callback();
|
|
247
|
+
}, 5000);
|
|
248
|
+
|
|
249
|
+
// Return an unsubscribe function
|
|
250
|
+
return () => clearInterval(intervalId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Extracts user information from the current JWT token.
|
|
255
|
+
* Returns null if no valid token is available.
|
|
256
|
+
*/
|
|
257
|
+
getUser(): { userId: string; email: string } | null {
|
|
258
|
+
if (!this.token) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
// JWT structure: header.payload.signature
|
|
264
|
+
const parts = this.token.token.split(".");
|
|
265
|
+
if (parts.length !== 3) {
|
|
266
|
+
this.log("Invalid JWT format - expected 3 parts", {
|
|
267
|
+
parts: parts.length,
|
|
268
|
+
});
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Decode the payload (second part)
|
|
273
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
274
|
+
|
|
275
|
+
if (!payload.sub) {
|
|
276
|
+
this.log("JWT payload missing 'sub' claim");
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
userId: payload.sub,
|
|
282
|
+
email: payload.email ?? "",
|
|
283
|
+
};
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this.log("Failed to decode JWT", error);
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
2
|
+
import type { SessionManager } from "./session-manager";
|
|
3
|
+
|
|
4
|
+
export const useSessionTokenClientMiddleware = createMiddleware({
|
|
5
|
+
type: "function",
|
|
6
|
+
}).client(async ({ next }) => {
|
|
7
|
+
// Get the global sessionManager - this MUST be available for embedded apps
|
|
8
|
+
const sessionManager = (window as any)
|
|
9
|
+
.__embeddedSessionManager as SessionManager;
|
|
10
|
+
|
|
11
|
+
if (!sessionManager) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
"[AuthMiddleware] SessionManager not available - embedded provider not initialized",
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// INVARIANT: This is just an extra check and should never be the case if the sessionManager exists.
|
|
18
|
+
if (typeof sessionManager.getToken !== "function") {
|
|
19
|
+
throw new Error(
|
|
20
|
+
"[AuthMiddleware] SessionManager.getToken is not a function",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const token = await sessionManager.getToken();
|
|
25
|
+
|
|
26
|
+
return next({
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${token}`,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Type definitions for environment variables expected by the SDK
|
|
2
|
+
// Apps using this SDK should have these defined in their wrangler configuration
|
|
3
|
+
|
|
4
|
+
declare namespace Cloudflare {
|
|
5
|
+
interface Env {
|
|
6
|
+
GATEWAY_URL: string;
|
|
7
|
+
EVERY_APP_GATEWAY?: Fetcher;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Env extends Cloudflare.Env {}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AuthConfig } from "./types";
|
|
2
|
+
import { env } from "cloudflare:workers";
|
|
3
|
+
|
|
4
|
+
export function getAuthConfig(): AuthConfig {
|
|
5
|
+
return {
|
|
6
|
+
jwksUrl: `${env.GATEWAY_URL}/api/embedded/jwks`,
|
|
7
|
+
issuer: env.GATEWAY_URL,
|
|
8
|
+
audience: import.meta.env.VITE_APP_ID,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
2
|
+
import {
|
|
3
|
+
createLocalJWKSet,
|
|
4
|
+
jwtVerify,
|
|
5
|
+
JWTVerifyOptions,
|
|
6
|
+
JSONWebKeySet,
|
|
7
|
+
} from "jose";
|
|
8
|
+
|
|
9
|
+
import type { AuthConfig } from "./types";
|
|
10
|
+
import { env } from "cloudflare:workers";
|
|
11
|
+
|
|
12
|
+
interface SessionTokenPayload {
|
|
13
|
+
sub: string;
|
|
14
|
+
iss: string;
|
|
15
|
+
aud: string;
|
|
16
|
+
exp: number;
|
|
17
|
+
iat: number;
|
|
18
|
+
appId?: string;
|
|
19
|
+
permissions?: string[];
|
|
20
|
+
email?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function authenticateRequest(
|
|
24
|
+
authConfig: AuthConfig,
|
|
25
|
+
providedRequest?: Request,
|
|
26
|
+
): Promise<SessionTokenPayload | null> {
|
|
27
|
+
const request = providedRequest || getRequest();
|
|
28
|
+
const authHeader = request.headers.get("authorization");
|
|
29
|
+
|
|
30
|
+
if (!authHeader) {
|
|
31
|
+
console.log("No auth header found");
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const token = extractBearerToken(authHeader);
|
|
36
|
+
|
|
37
|
+
if (!token) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const session = await verifySessionToken(token, authConfig);
|
|
43
|
+
return session;
|
|
44
|
+
// TODO Is there a way to handle this more gracefully?
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.error(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
message: "Error verifying session token",
|
|
49
|
+
error: error instanceof Error ? error.message : String(error),
|
|
50
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
51
|
+
errorType: error instanceof Error ? error.constructor.name : "Unknown",
|
|
52
|
+
authConfig,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function verifySessionToken(
|
|
60
|
+
token: string,
|
|
61
|
+
config: AuthConfig,
|
|
62
|
+
): Promise<SessionTokenPayload> {
|
|
63
|
+
const { issuer, audience } = config;
|
|
64
|
+
|
|
65
|
+
if (!issuer) {
|
|
66
|
+
throw new Error("Issuer must be provided for token verification");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!audience) {
|
|
70
|
+
throw new Error("Audience must be provided for token verification");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// TODO Maybe we don't even need this if we just store the jwks as an env when we deploy
|
|
74
|
+
// But, the limitation of these services not being able to talk to each other will be frustrating.
|
|
75
|
+
// I wonder if there is a better abstraction to wrap this dynamic fetching and link all the services together.
|
|
76
|
+
const jwksResponse =
|
|
77
|
+
import.meta.env.PROD && env.EVERY_APP_GATEWAY
|
|
78
|
+
? await env.EVERY_APP_GATEWAY.fetch("http://localhost/api/embedded/jwks")
|
|
79
|
+
: await fetch(`${env.GATEWAY_URL}/api/embedded/jwks`);
|
|
80
|
+
|
|
81
|
+
if (!jwksResponse.ok) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Failed to fetch JWKS: ${jwksResponse.status} ${jwksResponse.statusText}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const jwks = (await jwksResponse.json()) as JSONWebKeySet;
|
|
88
|
+
const localJWKS = createLocalJWKSet(jwks);
|
|
89
|
+
|
|
90
|
+
const options: JWTVerifyOptions = {
|
|
91
|
+
issuer,
|
|
92
|
+
audience,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const { payload } = await jwtVerify(token, localJWKS, options);
|
|
96
|
+
return payload as SessionTokenPayload;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function extractBearerToken(authHeader: string | null): string | null {
|
|
100
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return authHeader.substring(7);
|
|
104
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { parse } from "jsonc-parser";
|
|
5
|
+
|
|
6
|
+
export function getLocalD1Url() {
|
|
7
|
+
const basePath = path.resolve(".wrangler");
|
|
8
|
+
|
|
9
|
+
// Check if .wrangler directory exists
|
|
10
|
+
if (!fs.existsSync(basePath)) {
|
|
11
|
+
console.error(
|
|
12
|
+
"================================================================================",
|
|
13
|
+
);
|
|
14
|
+
console.error("WARNING: .wrangler directory not found");
|
|
15
|
+
console.error("This is expected in CI/non-development environments.");
|
|
16
|
+
console.error(
|
|
17
|
+
"The local D1 database is only available after running 'wrangler dev' which you can trigger by running 'npm run dev'.",
|
|
18
|
+
);
|
|
19
|
+
console.error(
|
|
20
|
+
"================================================================================",
|
|
21
|
+
);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const dbFile = fs
|
|
26
|
+
.readdirSync(basePath, { encoding: "utf-8", recursive: true })
|
|
27
|
+
.find((f) => f.endsWith(".sqlite"));
|
|
28
|
+
|
|
29
|
+
if (!dbFile) {
|
|
30
|
+
// Read wrangler.jsonc to get the database name
|
|
31
|
+
const wranglerConfigPath = path.resolve("wrangler.jsonc");
|
|
32
|
+
const wranglerConfig = parse(fs.readFileSync(wranglerConfigPath, "utf-8"));
|
|
33
|
+
|
|
34
|
+
const databaseName = wranglerConfig.d1_databases?.[0]?.database_name;
|
|
35
|
+
|
|
36
|
+
if (!databaseName) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"Could not find database_name in wrangler.jsonc d1_databases configuration",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Execute the command to initialize the local database
|
|
43
|
+
console.log(`Initializing local D1 database: ${databaseName}...`);
|
|
44
|
+
execSync(
|
|
45
|
+
`npx wrangler d1 execute ${databaseName} --local --command "SELECT 1;"`,
|
|
46
|
+
{ stdio: "pipe" },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Try to find the db file again after initialization
|
|
50
|
+
const dbFileAfterInit = fs
|
|
51
|
+
.readdirSync(basePath, { encoding: "utf-8", recursive: true })
|
|
52
|
+
.find((f) => f.endsWith(".sqlite"));
|
|
53
|
+
|
|
54
|
+
if (!dbFileAfterInit) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Failed to initialize local D1 database. The sqlite file was not created.`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const url = path.resolve(basePath, dbFileAfterInit);
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const url = path.resolve(basePath, dbFile);
|
|
65
|
+
return url;
|
|
66
|
+
}
|