diff --git a/src/client.ts b/src/client.ts index fe0c9834..76c21723 100644 --- a/src/client.ts +++ b/src/client.ts @@ -8,6 +8,10 @@ import { createUserConnectorsModule, } from "./modules/connectors.js"; import { getAccessToken } from "./utils/auth-utils.js"; +import { + exchangeEmbedToken, + takeEmbedTokenFromUrl, +} from "./utils/embed-session.js"; import { createFetchWithAuth } from "./utils/fetch-with-auth.js"; import { createFunctionsModule } from "./modules/functions.js"; import { createAgentsModule } from "./modules/agents.js"; @@ -79,7 +83,6 @@ export function createClient(config: CreateClientConfig): Base44Client { serverUrl = "https://base44.app", appId, analytics, - token, serviceToken, requiresAuth = false, appBaseUrl, @@ -91,12 +94,19 @@ export function createClient(config: CreateClientConfig): Base44Client { // Normalize appBaseUrl to always be a string (empty if not provided or invalid) const normalizedAppBaseUrl = typeof appBaseUrl === "string" ? appBaseUrl : ""; + const embedOtt = takeEmbedTokenFromUrl(); + + // A declaration, not a const: this block sits above the auth module. + function getToken(): string | null { + return userAuthModule.getToken() ?? (embedOtt ? null : getAccessToken()); + } + const socketConfig: RoomsSocketConfig = { serverUrl, mountPath: "/ws-user-apps/socket.io/", transports: ["websocket"], appId, - token, + getToken, }; let socket: ReturnType | null = null; @@ -110,6 +120,10 @@ export function createClient(config: CreateClientConfig): Base44Client { return socket; }; + // Apps pass getAccessToken() in as `token`, which in a frame is the OTT — + // what the exchange trades for a session, never a bearer itself. + const token = embedOtt ? undefined : config.token; + const headers = { ...optionalHeaders, "X-App-Id": String(appId), @@ -174,6 +188,11 @@ export function createClient(config: CreateClientConfig): Base44Client { appBaseUrl: normalizedAppBaseUrl, serverUrl, token, + embedded: Boolean(embedOtt), + // The socket carries its token on the handshake, so it can only pick a + // new one up by redialling — or, on logout, by dropping what it has. + onSessionChange: (hasSession) => + hasSession ? socket?.reconnect() : socket?.disconnect(), } ); @@ -181,13 +200,53 @@ export function createClient(config: CreateClientConfig): Base44Client { // requests during construction (notably analytics, which fires an init // event whose flush calls auth.me()). Without this, the first User/me // request is built before setToken runs and goes out unauthenticated. - if (typeof window !== "undefined") { + // Not in a frame: a stored token there belongs to an earlier visitor. + if (typeof window !== "undefined" && !embedOtt) { const accessToken = token || getAccessToken(); if (accessToken) { userAuthModule.setToken(accessToken); } } + const session = embedOtt + ? exchangeEmbedToken({ serverUrl, appId, ott: embedOtt }) + : null; + + // Never rejects: every request waits on it, so one failure here must not + // become a rejection on each of them. + const authReady: Promise = session + ? session + .then((sessionToken) => { + if (sessionToken) { + userAuthModule.setToken(sessionToken, false); + return; + } + const error = new Error( + "Base44: the embed token was refused, so this app is not signed in.", + ); + console.error(error.message); + options?.onError?.(error); + }) + .catch((e) => { + console.error("Base44: applying the embedded session failed:", e); + }) + : Promise.resolve(); + + if (session) { + // Registered after createAxiosClient's so it runs first (axios unshifts), + // letting the anonymous-visitor header see the Authorization we just set. + for (const client of [axiosClient, functionsAxiosClient]) { + client.interceptors.request.use(async (requestConfig) => { + await authReady; + const sessionToken = getToken(); + if (sessionToken && !requestConfig.headers.get("Authorization")) { + requestConfig.headers.set("Authorization", `Bearer ${sessionToken}`); + } + return requestConfig; + }); + } + } + const actorsModule = createActorsModule({ appId, // serverUrl is often relative/empty (same-origin app); the proxy-fallback @@ -197,9 +256,13 @@ export function createClient(config: CreateClientConfig): Base44Client { typeof window !== "undefined" ? window.location?.origin : undefined, ), functionsVersion, - getAuthToken: () => token || getAccessToken(), + getAuthToken: async () => { + await authReady; + return getToken(); + }, mintConnectionToken: async (actorName, room, connectionId) => { - const authToken = token || getAccessToken(); + await authReady; + const authToken = getToken(); return await actorsAxiosClient.post( `/apps/${appId}/actors/${encodeURIComponent(actorName)}/connection-token`, { room, connection_id: connectionId }, @@ -229,12 +292,12 @@ export function createClient(config: CreateClientConfig): Base44Client { connectors: createUserConnectorsModule(axiosClient, appId), auth: userAuthModule, functions: createFunctionsModule(functionsAxiosClient, appId, { + waitForAuth: () => authReady, getAuthHeaders: () => { const headers: Record = {}; - // Get current token from storage or initial config - const currentToken = token || getAccessToken(); - if (currentToken) { - headers["Authorization"] = `Bearer ${currentToken}`; + const sessionToken = getToken(); + if (sessionToken) { + headers["Authorization"] = `Bearer ${sessionToken}`; } return headers; }, @@ -245,9 +308,10 @@ export function createClient(config: CreateClientConfig): Base44Client { getSocket, appId, serverUrl, - token, + // Sync, unlike everything else: these return a URL, not a promise. + getToken, }), - aiGateway: createAiGatewayModule({ serverUrl, token, appId }), + aiGateway: createAiGatewayModule({ serverUrl, getToken, appId }), appLogs: createAppLogsModule(axiosClient, appId), app: createAppModule(axiosClient, appId), users: createUsersModule(axiosClient, appId), @@ -293,9 +357,15 @@ export function createClient(config: CreateClientConfig): Base44Client { getSocket, appId, serverUrl, - token, + // The user's token, deliberately: this is read only for the `?token=` on + // a channel URL handed to that user. Never the service credential. + getToken, + }), + aiGateway: createAiGatewayModule({ + serverUrl, + getToken: () => serviceToken, + appId, }), - aiGateway: createAiGatewayModule({ serverUrl, token: serviceToken, appId }), appLogs: createAppLogsModule(serviceRoleAxiosClient, appId), cleanup: () => { if (socket) { @@ -332,6 +402,7 @@ export function createClient(config: CreateClientConfig): Base44Client { serverUrl, functionsVersion, platformHeaders: optionalHeaders, + waitForAuth: () => authReady, }), /** @@ -350,13 +421,7 @@ export function createClient(config: CreateClientConfig): Base44Client { * ``` */ setToken(newToken: string) { - userModules.auth.setToken(newToken); - if (socket) { - socket.updateConfig({ - token: newToken, - }); - } - socketConfig.token = newToken; + userAuthModule.setToken(newToken, true); }, /** diff --git a/src/modules/actors.ts b/src/modules/actors.ts index 493b2078..fb964b8c 100644 --- a/src/modules/actors.ts +++ b/src/modules/actors.ts @@ -19,8 +19,9 @@ interface ActorsConfig { appId: string; /** Current user access token, if authenticated. Rides the WS query on the * proxy-fallback path so the platform proxy can authenticate the connection; - * anonymous connects omit it. */ - getAuthToken(): string | null | undefined; + * anonymous connects omit it. Awaited per dial, so a session still being + * exchanged is in hand before the URL is built. */ + getAuthToken(): Promise; /** Same semantics as function calls: editors with a non-prod version get the * draft actor script; everyone else gets the published one. */ functionsVersion?: string; @@ -150,7 +151,7 @@ class Connection { instanceId, this.id, config.appId, - config.getAuthToken(), + await config.getAuthToken(), config.functionsVersion, ); }; diff --git a/src/modules/agents.ts b/src/modules/agents.ts index 015261fc..1a213cbd 100644 --- a/src/modules/agents.ts +++ b/src/modules/agents.ts @@ -1,4 +1,3 @@ -import { getAccessToken } from "../utils/auth-utils.js"; import { ModelFilterParams } from "../types.js"; import { AgentConversation, @@ -13,7 +12,7 @@ export function createAgentsModule({ getSocket, appId, serverUrl, - token, + getToken, }: AgentsModuleConfig): AgentsModule { const baseURL = `/apps/${appId}/agents`; @@ -102,7 +101,7 @@ export function createAgentsModule({ const baseUrl = `${serverUrl}/api/apps/${appId}/agents/${encodeURIComponent( agentName )}/whatsapp`; - const accessToken = token ?? getAccessToken(); + const accessToken = getToken(); if (accessToken) { return `${baseUrl}?token=${accessToken}`; @@ -116,7 +115,7 @@ export function createAgentsModule({ const baseUrl = `${serverUrl}/api/apps/${appId}/agents/${encodeURIComponent( agentName )}/telegram`; - const accessToken = token ?? getAccessToken(); + const accessToken = getToken(); if (accessToken) { return `${baseUrl}?token=${accessToken}`; diff --git a/src/modules/agents.types.ts b/src/modules/agents.types.ts index 51e079da..8f6370f4 100644 --- a/src/modules/agents.types.ts +++ b/src/modules/agents.types.ts @@ -172,8 +172,8 @@ export interface AgentsModuleConfig { appId: string; /** Server URL */ serverUrl?: string; - /** Authentication token */ - token?: string; + /** Returns the current authentication token, if any */ + getToken: () => string | null; } /** @@ -391,7 +391,9 @@ export interface AgentsModule { * Gets WhatsApp connection URL for an agent. * * Generates a URL that users can use to connect with the agent through WhatsApp. - * The URL includes authentication if a token is available. + * The URL includes authentication if a token is available. In an app a + * platform has embedded, that is only once the session has been exchanged — + * await a call such as `base44.auth.me()` before building the URL. * * @param agentName - The name of the agent. * @returns WhatsApp connection URL. @@ -410,7 +412,9 @@ export interface AgentsModule { * Gets Telegram connection URL for an agent. * * Generates a URL that users can use to connect with the agent through Telegram. - * The URL includes authentication if a token is available. When the user opens + * The URL includes authentication if a token is available. In an app a + * platform has embedded, that is only once the session has been exchanged — + * await a call such as `base44.auth.me()` before building the URL. When the user opens * this URL, they are redirected to the agent's Telegram bot with an activation * code that securely links their account. * diff --git a/src/modules/ai-gateway.ts b/src/modules/ai-gateway.ts index 30939603..ac6950c8 100644 --- a/src/modules/ai-gateway.ts +++ b/src/modules/ai-gateway.ts @@ -1,4 +1,3 @@ -import { getAccessToken } from "../utils/auth-utils.js"; import { AiGatewayModule, AiGatewayModuleConfig, @@ -7,12 +6,12 @@ import { export function createAiGatewayModule({ serverUrl, - token, + getToken, appId, }: AiGatewayModuleConfig): AiGatewayModule { const connection = (): AiGatewayConnection => ({ baseURL: `${serverUrl}/api/apps/${appId}/ai/openai/v1`, - token: token ?? getAccessToken() ?? "", + token: getToken() ?? "", }); return { diff --git a/src/modules/ai-gateway.types.ts b/src/modules/ai-gateway.types.ts index c94fac71..54d98984 100644 --- a/src/modules/ai-gateway.types.ts +++ b/src/modules/ai-gateway.types.ts @@ -18,8 +18,8 @@ export interface AiGatewayConnection { export interface AiGatewayModuleConfig { /** Server URL */ serverUrl?: string; - /** Authentication token */ - token?: string; + /** Returns the current authentication token, if any */ + getToken: () => string | null | undefined; /** Application ID */ appId: string; } diff --git a/src/modules/auth.ts b/src/modules/auth.ts index b9e23747..40ee6df2 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -8,6 +8,7 @@ import { ResetPasswordParams, } from "./auth.types"; import { resetAnalyticsSessionContext } from "./analytics.js"; +import { showEmbedSessionEnded } from "../utils/embed-session.js"; function isInsideIframe(): boolean { if (typeof window === "undefined") return false; @@ -111,11 +112,19 @@ export function createAuthModule( // Tracked here rather than read off `axios.defaults` so the answer stays tied // to the identity transitions below (`setToken`, `logout`) instead of to the // header a caller may have set on the instance directly. - let hasAccessToken = Boolean(options.token); + let accessToken: string | null = options.token || null; return { hasToken() { - return hasAccessToken; + return accessToken !== null; + }, + + getToken() { + return accessToken; + }, + + isEmbedded() { + return Boolean(options.embedded); }, // Get current user information @@ -147,6 +156,16 @@ export function createAuthModule( ); } + // Only the host platform can sign a platform user in, so there is no + // login page to send them to. (The app's own login would work in the + // frame — `loginWithProvider` opens a popup — but it would mint a + // different, app-level identity.) An app that wants its own notice + // checks `isEmbedded()` rather than asking for a login it cannot get. + if (options.embedded) { + showEmbedSessionEnded(); + return; + } + // If nextUrl is not provided, use the current URL const redirectUrl = nextUrl ? new URL(nextUrl, window.location.origin).toString() @@ -191,14 +210,18 @@ export function createAuthModule( // Logout the current user logout(redirectUrl?: string) { - // Remove token from axios headers (always do this) + // Remove the token from both axios instances (always do this). Missing + // the functions one used to be hidden by the redirect below tearing the + // page down; an embedded logout returns instead, so the page lives on. delete axios.defaults.headers.common["Authorization"]; + delete functionsAxiosClient.defaults.headers.common["Authorization"]; // Drop identity resolved under the previous session: a `me()` already in // flight would otherwise resolve into callers that run after the logout. clearPendingMe(); resetAnalyticsSessionContext(); - hasAccessToken = false; + accessToken = null; + options.onSessionChange?.(false); // Only do the rest if in a browser environment if (typeof window !== "undefined") { @@ -213,6 +236,14 @@ export function createAuthModule( } } + // An embedded session holds no app cookie to clear — it lived in + // memory — and navigating a third-party frame to the logout endpoint + // would only break the frame. The state above is already cleared. + if (options.embedded) { + showEmbedSessionEnded(); + return; + } + // Determine the from_url parameter const fromUrl = redirectUrl || window.location.href; @@ -226,24 +257,26 @@ export function createAuthModule( setToken(token: string, saveToStorage = true) { if (!token) return; + // An embedded session belongs to the frame the platform minted it for. + // Persisting it would let it outlive that frame and be picked up as the + // identity on a later top-level visit, so storage is refused outright. + const persist = saveToStorage && !options.embedded; + // Same reasoning as in `logout`: the identity changes here, so anything // resolved for the previous one must not be handed to later callers. clearPendingMe(); resetAnalyticsSessionContext(); - hasAccessToken = true; + accessToken = token; // handle token change for axios clients axios.defaults.headers.common["Authorization"] = `Bearer ${token}`; functionsAxiosClient.defaults.headers.common[ "Authorization" ] = `Bearer ${token}`; + options.onSessionChange?.(true); // Save token to localStorage if requested - if ( - saveToStorage && - typeof window !== "undefined" && - window.localStorage - ) { + if (persist && typeof window !== "undefined" && window.localStorage) { try { window.localStorage.setItem("base44_access_token", token); // Set "token" that is set by the built-in SDK of platform version 2 diff --git a/src/modules/auth.types.ts b/src/modules/auth.types.ts index 7c080efe..3c5c2169 100644 --- a/src/modules/auth.types.ts +++ b/src/modules/auth.types.ts @@ -106,6 +106,13 @@ export interface AuthModuleOptions { * which is how the server-side SDK reports a token it never sets explicitly. */ token?: string; + /** Whether a host platform embedded this client in a frame. */ + embedded?: boolean; + /** + * Called when the identity changes: `true` on `setToken`, `false` on + * `logout`. Lets the client redial the socket, which holds its own copy. + */ + onSessionChange?: (hasSession: boolean) => void; } /** @@ -193,6 +200,25 @@ export interface AuthModule { */ redirectToLogin(nextUrl: string): void; + /** + * Whether a host platform embedded this app and signed its user in. + * + * Only the platform can renew that session, so {@linkcode AuthModule.redirectToLogin | redirectToLogin()} and {@linkcode AuthModule.logout | logout()} show a "session ended" notice rather than a login page. Use this to render your own notice, or to hide sign-in controls that cannot work in the frame. + * + * The session lives in memory, so a reload inside the frame ends it and returns `false` here. Prefer client-side navigation. + * + * @returns `true` when a host platform embedded this app. + * + * @example + * ```typescript + * // Show your own message instead of a login screen + * if (!user && base44.auth.isEmbedded()) { + * return ; + * } + * ``` + */ + isEmbedded(): boolean; + /** * Redirects the user to a third-party authentication provider's login page. * @@ -569,4 +595,7 @@ export interface InternalAuthModule extends AuthModule { * could not succeed without a session, not to decide that one is valid. */ hasToken(): boolean; + + /** The token currently set on the client, or `null`. */ + getToken(): string | null; } diff --git a/src/modules/functions.ts b/src/modules/functions.ts index 1a72c1d3..ba9ed5eb 100644 --- a/src/modules/functions.ts +++ b/src/modules/functions.ts @@ -88,6 +88,9 @@ export function createFunctionsModule( const normalizedPath = path.startsWith("/") ? path : `/${path}`; const primaryPath = `/functions${normalizedPath}`; + // Headers are read after this: a session still being negotiated must be + // in hand before the Authorization is built, not after. + await config?.waitForAuth?.(); const headers = toHeaders(init.headers); const requestInit: RequestInit = { diff --git a/src/modules/functions.types.ts b/src/modules/functions.types.ts index 65e5f0d4..c0455892 100644 --- a/src/modules/functions.types.ts +++ b/src/modules/functions.types.ts @@ -33,6 +33,12 @@ export type FunctionsFetchInit = RequestInit; export interface FunctionsModuleConfig { getAuthHeaders?: () => Record; baseURL?: string; + /** + * Resolves once the client's session is settled. `fetch` builds its headers + * by hand rather than through axios, so without this it would miss the gate + * every other request goes through. + */ + waitForAuth?: () => Promise; } /** diff --git a/src/utils/auth-utils.ts b/src/utils/auth-utils.ts index ff82c4cc..c949a5da 100644 --- a/src/utils/auth-utils.ts +++ b/src/utils/auth-utils.ts @@ -4,6 +4,10 @@ import { RemoveAccessTokenOptions, GetLoginUrlOptions, } from "./auth-utils.types.js"; +import { EMBED_TOKEN_PARAM, isFramed } from "./embed-session.js"; + +/** The URL parameter a Base44 session token arrives on. */ +const DEFAULT_TOKEN_PARAM = "access_token"; /** * Retrieves an access token from URL parameters or local storage. @@ -11,6 +15,8 @@ import { * Low-level utility for manually retrieving tokens. In most cases, the Base44 client handles * token management automatically. This function is useful for custom authentication flows or when you need direct access to stored tokens. Requires a browser environment and can't be used in the backend. * + * When a host platform has embedded the app with a one-time token (`?ott=`), that token is returned as it stands: it is what {@linkcode createClient} trades for the session, so a page that gates on "is there a token?" as it loads sees one. It is neither stored nor removed from the URL here, and it is reported only inside a frame, where such a token is redeemed. + * * @internal * * @param options - Configuration options for token retrieval. @@ -44,7 +50,7 @@ import { export function getAccessToken(options: GetAccessTokenOptions = {}) { const { storageKey = "base44_access_token", - paramName = "access_token", + paramName = DEFAULT_TOKEN_PARAM, saveToStorage = true, removeFromUrl = true, } = options; @@ -75,6 +81,22 @@ export function getAccessToken(options: GetAccessTokenOptions = {}) { return token; } + + // A platform-embedded load carries a one-time token instead. It is not a + // session yet — createClient takes it off the URL and exchanges it — but + // it is the identity this load arrives with, and callers that read this + // once as the page loads must not conclude there is none. + // + // Only in a frame, and only for the parameter a session arrives on: a + // one-time token is redeemed nowhere else, so a top-level load still + // carrying one (a URL rewrite the browser refused) must not have it + // applied as a session — and never saved as one. + if (paramName === DEFAULT_TOKEN_PARAM && isFramed()) { + const embedToken = urlParams.get(EMBED_TOKEN_PARAM); + if (embedToken) { + return embedToken; + } + } } catch (e) { console.error("Error retrieving token from URL:", e); } diff --git a/src/utils/embed-session.ts b/src/utils/embed-session.ts new file mode 100644 index 00000000..ca0136c6 --- /dev/null +++ b/src/utils/embed-session.ts @@ -0,0 +1,133 @@ +/** + * Sessions for apps embedded in a host platform. + * + * The platform's server mints a one-time token for one of its users and puts + * it on the iframe URL as `?ott=`. The client takes it off the URL as it is + * created, trades it for an app-user session through the OAuth token-exchange + * grant (RFC 8693), and keeps the result in memory only — never in storage — so + * the session lives and dies with the frame. + * + * Taking the token off the URL is unconditional — a one-time token must not be + * left in the address bar, in history, or in a shared link — but it is only + * reported back inside a frame, the only place one is redeemed. It is gone once + * the first client has taken it, so the session belongs to that client — an app + * creates one. + * + * The exchange endpoint is rate limited per app, and its limiter refuses before + * the one-time token is redeemed — so a 429 leaves the token still valid and is + * worth retrying once. + * + * @internal + */ + +export const EMBED_TOKEN_PARAM = "ott"; +const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"; +const SUBJECT_TOKEN_TYPE = "urn:base44:params:oauth:token-type:embed-ott"; +const RATE_LIMITED = 429; +const RATE_LIMIT_RETRY_MS = 1000; +const EXCHANGE_TIMEOUT_MS = 15_000; +const SESSION_ENDED_ELEMENT_ID = "base44-embed-session-ended"; + +/** @internal */ +export function takeEmbedTokenFromUrl(): string | null { + if (typeof window === "undefined" || !window.location) { + return null; + } + let ott: string | null = null; + try { + const url = new URL(window.location.href); + ott = url.searchParams.get(EMBED_TOKEN_PARAM); + if (!ott) { + return null; + } + url.searchParams.delete(EMBED_TOKEN_PARAM); + window.history.replaceState(window.history.state, "", url.toString()); + } catch (e) { + console.error("Error retrieving embed token from URL:", e); + } + return isFramed() ? ott : null; +} + +/** @internal */ +export function isFramed(): boolean { + return typeof window !== "undefined" && window.self !== window.top; +} + +/** @internal */ +export async function exchangeEmbedToken({ + serverUrl, + appId, + ott, + fetchImpl = fetch, +}: { + serverUrl: string; + appId: string; + ott: string; + fetchImpl?: typeof fetch; +}): Promise { + const post = () => + fetchImpl(`${serverUrl}/api/apps/${appId}/auth/embed/token`, { + method: "POST", + headers: { "X-App-Id": String(appId) }, + signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS), + body: new URLSearchParams({ + grant_type: GRANT_TYPE, + subject_token: ott, + subject_token_type: SUBJECT_TOKEN_TYPE, + }), + }); + + try { + let response = await post(); + if (response.status === RATE_LIMITED) { + await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_RETRY_MS)); + response = await post(); + } + if (!response.ok) { + return null; + } + const { access_token: token } = (await response.json()) as { + access_token?: string; + }; + return token || null; + } catch (e) { + console.error("Embed token exchange failed:", e); + return null; + } +} + +/** @internal */ +export function showEmbedSessionEnded(): void { + if (typeof document === "undefined" || !document.body) { + return; + } + if (document.getElementById(SESSION_ENDED_ELEMENT_ID)) { + return; + } + const style = document.createElement("style"); + style.textContent = + `#${SESSION_ENDED_ELEMENT_ID}{--b44-bg:#fff;--b44-fg:#0f172a;--b44-muted:#475569;}` + + `@media (prefers-color-scheme:dark){#${SESSION_ENDED_ELEMENT_ID}` + + `{--b44-bg:#0f172a;--b44-fg:#f8fafc;--b44-muted:#94a3b8;}}`; + + const overlay = document.createElement("div"); + overlay.id = SESSION_ENDED_ELEMENT_ID; + overlay.setAttribute("role", "alert"); + overlay.style.cssText = + "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;" + + "justify-content:center;background:var(--b44-bg);color:var(--b44-fg);" + + "font-family:ui-sans-serif,system-ui,sans-serif;text-align:center;padding:2rem;"; + + const title = document.createElement("h1"); + title.textContent = "Session ended"; + title.style.cssText = "font-size:1.5rem;font-weight:700;margin:0 0 .75rem;"; + + const body = document.createElement("p"); + body.textContent = "Reload this page in your browser to start a new session."; + body.style.cssText = "margin:0;color:var(--b44-muted);"; + + const card = document.createElement("div"); + card.append(title, body); + overlay.append(style, card); + document.body.append(overlay); +} diff --git a/src/utils/fetch-with-auth.ts b/src/utils/fetch-with-auth.ts index 9b954982..57c2dbca 100644 --- a/src/utils/fetch-with-auth.ts +++ b/src/utils/fetch-with-auth.ts @@ -42,6 +42,7 @@ export function createFetchWithAuth({ serverUrl, functionsVersion, platformHeaders, + waitForAuth, }: { axios: AxiosInstance; serviceRoleAxios: AxiosInstance; @@ -49,6 +50,7 @@ export function createFetchWithAuth({ serverUrl: string; functionsVersion?: string; platformHeaders?: Record; + waitForAuth?: () => Promise; }) { const inherited = new Headers(platformHeaders); @@ -65,6 +67,10 @@ export function createFetchWithAuth({ ): Promise { assertOwnOriginPath(path); + // The Authorization below is read off the axios defaults, which a session + // still being negotiated has not written yet. + await waitForAuth?.(); + const { fetch: transport = fetch, ...requestInit } = init; const headers = new Headers(init.headers); diff --git a/src/utils/socket-utils.ts b/src/utils/socket-utils.ts index e05f150c..b1fedaab 100644 --- a/src/utils/socket-utils.ts +++ b/src/utils/socket-utils.ts @@ -1,5 +1,4 @@ import { Socket, io } from "socket.io-client"; -import { getAccessToken } from "./auth-utils.js"; import { getAnalyticsSessionId } from "../modules/analytics.js"; export interface RoomsSocketConfig { @@ -7,7 +6,7 @@ export interface RoomsSocketConfig { mountPath: string; transports: string[]; appId: string; - token?: string; + getToken: () => string | null; } export type TSocketRoom = string; @@ -42,7 +41,7 @@ function initializeSocket( // handshake so the backend can verify room access for anonymous agent // conversations (mirrors the X-Base44-Anonymous-Id HTTP header). Authenticated // clients are identified by their token instead. - const resolvedToken = config.token ?? getAccessToken(); + const resolvedToken = config.getToken(); const query: Record = { app_id: config.appId, token: resolvedToken, @@ -81,7 +80,6 @@ function initializeSocket( export type RoomsSocket = ReturnType; export function RoomsSocket({ config }: { config: RoomsSocketConfig }) { - let currentConfig = { ...config }; const roomsToListeners: Record< TSocketRoom, Partial[] @@ -136,13 +134,10 @@ export function RoomsSocket({ config }: { config: RoomsSocketConfig }) { } } - function updateConfig(config: Partial) { + /** Drops the connection and opens a new one with the current token. */ + function reconnect() { cleanup(); - currentConfig = { - ...currentConfig, - ...config, - }; - socket = initializeSocket(currentConfig, handlers); + socket = initializeSocket(config, handlers); } function joinRoom(room: string) { @@ -229,7 +224,7 @@ export function RoomsSocket({ config }: { config: RoomsSocketConfig }) { return { socket, subscribeToRoom, - updateConfig, + reconnect, updateModel, disconnect, }; diff --git a/tests/unit/actors.test.ts b/tests/unit/actors.test.ts index e79516e1..d783c6bc 100644 --- a/tests/unit/actors.test.ts +++ b/tests/unit/actors.test.ts @@ -50,7 +50,7 @@ const httpError = (status: number) => const makeConfig = () => ({ appId: "app-1", - getAuthToken: () => "user-tok" as string | null, + getAuthToken: async () => "user-tok" as string | null, functionsVersion: undefined as string | undefined, host: "https://app.example", mintConnectionToken: vi.fn( @@ -307,7 +307,7 @@ describe("Actors Module — proxy fallback", () => { test("anonymous fallback omits the token; fv rides the query when set", async () => { const config = makeConfig(); - config.getAuthToken = () => null; + config.getAuthToken = async () => null; config.functionsVersion = "draft"; config.mintConnectionToken.mockRejectedValueOnce(httpError(409)); mod(config).GameRoom("r").connect({ id: "c" }); diff --git a/tests/unit/client-embed.test.ts b/tests/unit/client-embed.test.ts new file mode 100644 index 00000000..33d1eb64 --- /dev/null +++ b/tests/unit/client-embed.test.ts @@ -0,0 +1,510 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import nock from "nock"; +import { io } from "socket.io-client"; +import { createClient, getAccessToken } from "../../src/index.ts"; + +vi.mock("socket.io-client", () => ({ + io: vi.fn(() => ({ + id: "socket-id", + disconnect: vi.fn(), + emit: vi.fn(), + on: vi.fn(), + })), +})); + +const serverUrl = "https://api.base44.test"; +const appId = "app-1"; +const exchangePath = `/api/apps/${appId}/auth/embed/token`; + +const fakeStorage = () => { + const values = new Map(); + return { + getItem: vi.fn((key: string) => values.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => void values.set(key, value)), + removeItem: vi.fn((key: string) => void values.delete(key)), + }; +}; + +// A browser window as an embedded app sees it: inside a frame, with the +// platform's one-time token on the URL unless `url` says otherwise. +const stubBrowser = ({ + url = `https://app.base44.app/orders?ott=the-ott&tab=open`, + framed = true, + localStorage = fakeStorage(), + sessionStorage = fakeStorage(), +} = {}) => { + const windowRef: any = { + location: { href: url, origin: "https://app.base44.app" }, + history: { + state: null, + replaceState: (_state: unknown, _title: string, nextUrl: string) => { + windowRef.location.href = nextUrl; + }, + }, + localStorage, + sessionStorage, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + windowRef.self = windowRef; + windowRef.top = framed ? {} : windowRef; + windowRef.parent = windowRef.top; + vi.stubGlobal("window", windowRef); + return windowRef; +}; + +// The exchange goes through global fetch; `resolveWith` releases it so a test +// can issue requests while it is still in flight. +const stubExchange = (result: { access_token?: string } | { status: number }) => { + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const fetchMock = vi.fn(async (url: string, init: RequestInit) => { + await gate; + if ("status" in result) { + return { ok: false, status: result.status, json: async () => ({ error: "invalid_grant" }) }; + } + return { ok: true, status: 200, json: async () => result }; + }); + vi.stubGlobal("fetch", fetchMock); + return { fetchMock, release }; +}; + +const newClient = () => + createClient({ serverUrl, appId, analytics: { enabled: false } }); + +// Enough of a document for the session-ended notice to render into. +const stubDocument = () => { + const appended: unknown[] = []; + vi.stubGlobal("document", { + getElementById: () => null, + createElement: () => ({ style: {}, setAttribute: vi.fn(), append: vi.fn() }), + body: { append: (...nodes: unknown[]) => appended.push(...nodes) }, + }); + return appended; +}; + +describe("embedded sessions", () => { + beforeEach(() => { + nock.cleanAll(); + vi.mocked(io).mockClear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + nock.cleanAll(); + }); + + test("takes the one-time token off the URL as the client is created", () => { + const windowRef = stubBrowser(); + stubExchange({ access_token: "session-token" }); + + newClient(); + + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + }); + + test("exchanges the token and authenticates a request issued while the exchange is in flight", async () => { + stubBrowser(); + const { fetchMock, release } = stubExchange({ access_token: "session-token" }); + const me = nock(serverUrl, { reqheaders: { authorization: "Bearer session-token" } }) + .get(`/api/apps/${appId}/entities/User/me`) + .reply(200, { id: "u1", email: "bob@platform.test" }); + + const client = newClient(); + expect(client.auth.hasToken()).toBe(false); + + const pending = client.auth.me(); + release(); + const user = await pending; + + expect(user.email).toBe("bob@platform.test"); + expect(client.auth.hasToken()).toBe(true); + expect(me.isDone()).toBe(true); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe(`${serverUrl}${exchangePath}`); + expect(Object.fromEntries(init.body as URLSearchParams)).toMatchObject({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: "the-ott", + }); + }); + + test("keeps the session in memory only", async () => { + const localStorage = fakeStorage(); + stubBrowser({ localStorage }); + const { release } = stubExchange({ access_token: "session-token" }); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: "u1" }); + + const client = newClient(); + release(); + await client.auth.me(); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + // Apps read getAccessToken() as they load and hand the result to createClient + // as `token` — in a frame that is the one-time token itself. It is proof of + // an identity, not the identity: it is never sent as a bearer. + test("a one-time token passed in as the token is exchanged, not sent", async () => { + stubBrowser(); + const { release } = stubExchange({ access_token: "session-token" }); + const me = nock(serverUrl, { reqheaders: { authorization: "Bearer session-token" } }) + .get(`/api/apps/${appId}/entities/User/me`) + .reply(200, { id: "u1" }); + + const client = createClient({ + serverUrl, + appId, + token: "the-ott", + analytics: { enabled: false }, + }); + expect(client.auth.hasToken()).toBe(false); + expect(client.aiGateway.connection().token).toBe(""); + + const pending = client.auth.me(); + release(); + await pending; + + expect(me.isDone()).toBe(true); + expect(client.aiGateway.connection().token).toBe("session-token"); + }); + + test("ignores a token an earlier visitor left in storage", async () => { + const localStorage = fakeStorage(); + localStorage.setItem("base44_access_token", "alice-token"); + localStorage.setItem.mockClear(); + stubBrowser({ localStorage }); + const { release } = stubExchange({ status: 400 }); + // The request must go out anonymous — never as alice. + const me = nock(serverUrl, { badheaders: ["authorization"] }) + .get(`/api/apps/${appId}/entities/User/me`) + .reply(401, { detail: "unauthenticated" }); + + const client = newClient(); + release(); + + await expect(client.auth.me()).rejects.toBeTruthy(); + expect(me.isDone()).toBe(true); + expect(client.auth.hasToken()).toBe(false); + expect(client.auth.isEmbedded()).toBe(true); + expect(client.aiGateway.connection().token).toBe(""); + }); + + test("the realtime socket connects with the exchanged session", async () => { + stubBrowser(); + const { release } = stubExchange({ access_token: "session-token" }); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: "u1" }); + + const client = newClient(); + release(); + await client.auth.me(); + client.entities.Todo.subscribe(() => {}); + + const [, options] = vi.mocked(io).mock.calls.at(-1)!; + expect((options as { query: Record }).query.token).toBe("session-token"); + }); + + // functions.fetch builds its own headers instead of going through axios, so + // it has to wait for the exchange on its own or it leaves unauthenticated. + test("functions.fetch waits for the exchange and carries the session", async () => { + stubBrowser(); + const { fetchMock, release } = stubExchange({ access_token: "session-token" }); + + const client = newClient(); + const pending = client.functions.fetch("/report"); + release(); + await pending; + + // The first fetch was the exchange; the second is the function call. + const [, init] = fetchMock.mock.calls.at(-1)!; + const headers = new Headers((init as RequestInit).headers); + expect(headers.get("Authorization")).toBe("Bearer session-token"); + }); + + // These build a URL instead of issuing a request, so they cannot wait for the + // exchange: they read empty while it is in flight and correct once it lands. + test("the value readers are empty until the session arrives", async () => { + stubBrowser(); + const { release } = stubExchange({ access_token: "session-token" }); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: "u1" }); + + const client = newClient(); + + expect(client.aiGateway.connection().token).toBe(""); + expect(client.agents.getWhatsAppConnectURL("bot")).not.toContain("token="); + + release(); + await client.auth.me(); + + expect(client.aiGateway.connection().token).toBe("session-token"); + expect(client.agents.getWhatsAppConnectURL("bot")).toContain("token=session-token"); + }); + + test("redirectToLogin shows a session-ended notice instead of leaving the frame", () => { + const windowRef = stubBrowser(); + stubExchange({ status: 400 }); + const appended = stubDocument(); + + const client = newClient(); + client.auth.redirectToLogin(windowRef.location.href); + + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + expect(appended).toHaveLength(1); + }); + + // Someone opens the iframe's own URL in a normal tab. There is no frame, so + // the regular login works and must be what they get — but the one-time token + // still comes off the URL rather than sitting in the address bar. + test("a top-level tab carrying a token is not embedded, and signs in normally", async () => { + const localStorage = fakeStorage(); + localStorage.setItem("base44_access_token", "stored-token"); + localStorage.setItem.mockClear(); + const windowRef = stubBrowser({ framed: false, localStorage }); + const { fetchMock } = stubExchange({ access_token: "never-used" }); + const me = nock(serverUrl, { reqheaders: { authorization: "Bearer stored-token" } }) + .get(`/api/apps/${appId}/entities/User/me`) + .reply(200, { id: "u1" }); + + const client = newClient(); + + expect(client.auth.isEmbedded()).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + + await client.auth.me(); + expect(me.isDone()).toBe(true); + }); + + test("an app opened normally is untouched: stored token applied, nothing exchanged", () => { + const localStorage = fakeStorage(); + localStorage.setItem("base44_access_token", "stored-token"); + stubBrowser({ url: "https://app.base44.app/orders", localStorage }); + const { fetchMock } = stubExchange({ access_token: "unused" }); + + const client = newClient(); + + expect(client.auth.isEmbedded()).toBe(false); + expect(client.auth.hasToken()).toBe(true); + expect(client.aiGateway.connection().token).toBe("stored-token"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // The exchange applies its session with saveToStorage=false, but an app can + // call setToken itself (loginViaEmailPassword does). In a frame that must + // not persist either, or the session outlives the frame it was minted for. + test("an embedded client never persists a token, even when asked to", async () => { + const localStorage = fakeStorage(); + stubBrowser({ localStorage }); + const { release } = stubExchange({ access_token: "session-token" }); + + const client = newClient(); + release(); + await Promise.resolve(); + + client.setToken("app-set-token"); + + expect(client.auth.hasToken()).toBe(true); + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + test("a refused exchange is reported through onError instead of only 401s", async () => { + stubBrowser(); + const { release } = stubExchange({ status: 400 }); + const onError = vi.fn(); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(401, {}); + + const client = createClient({ + serverUrl, + appId, + analytics: { enabled: false }, + options: { onError }, + }); + release(); + await expect(client.auth.me()).rejects.toBeTruthy(); + + const reported = onError.mock.calls.map(([e]) => (e as Error).message); + expect(reported.some((m) => m.includes("embed token was refused"))).toBe(true); + }); + + // The socket used to be handed a token at construction and had to be told + // about every later one; now it asks, so a reconnect is all this needs. + test("setToken reaches the socket", () => { + const localStorage = fakeStorage(); + stubBrowser({ url: "https://app.base44.app/orders", localStorage }); + stubExchange({ access_token: "unused" }); + + const client = newClient(); + client.entities.Todo.subscribe(() => {}); + client.setToken("fresh-token"); + + const [, options] = vi.mocked(io).mock.calls.at(-1)!; + expect((options as { query: Record }).query.token).toBe("fresh-token"); + expect(localStorage.setItem).toHaveBeenCalledWith("base44_access_token", "fresh-token"); + }); + + // Same for fetchWithAuth, which reads the Authorization off the axios + // defaults rather than building it. + test("fetchWithAuth waits for the exchange and carries the session", async () => { + stubBrowser(); + const { fetchMock, release } = stubExchange({ access_token: "session-token" }); + + const client = newClient(); + const pending = client.fetchWithAuth("/api/orders"); + release(); + await pending; + + const [, init] = fetchMock.mock.calls.at(-1)!; + const headers = new Headers((init as RequestInit).headers); + expect(headers.get("Authorization")).toBe("Bearer session-token"); + }); + + // The service-role module's channel URLs are handed to the user, so they + // carry the user's token — the app's service credential must never be in one. + test("a service-role channel URL carries the user's session, not the service token", async () => { + stubBrowser(); + const { release } = stubExchange({ access_token: "session-token" }); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: "u1" }); + + const client = createClient({ + serverUrl, + appId, + serviceToken: "service-token", + analytics: { enabled: false }, + }); + release(); + await client.auth.me(); + + const url = client.asServiceRole.agents.getWhatsAppConnectURL("support"); + expect(url).toContain("token=session-token"); + }); + + test("logout inside the frame shows the notice instead of navigating", () => { + const windowRef = stubBrowser(); + stubExchange({ access_token: "unused" }); + const appended = stubDocument(); + + const client = newClient(); + client.auth.logout(); + + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + expect(appended).toHaveLength(1); + expect(client.auth.hasToken()).toBe(false); + }); + + // The one-time token stays on the URL when the browser refuses the rewrite. + // Outside a frame it is redeemed by nobody, so it must not be mistaken for a + // session — least of all a saved one, which would outlast the visit. + test("a top-level load whose URL cannot be rewritten never saves the one-time token", () => { + const localStorage = fakeStorage(); + const windowRef = stubBrowser({ framed: false, localStorage }); + windowRef.history.replaceState = () => { + throw new Error("history blocked"); + }; + windowRef.location.search = "?ott=the-ott&tab=open"; + windowRef.location.pathname = "/orders"; + windowRef.location.hash = ""; + const { fetchMock } = stubExchange({ access_token: "never-used" }); + + const client = newClient(); + + expect(client.auth.hasToken()).toBe(false); + expect(localStorage.setItem).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + // The exchange is what every request waits for, so it has to end. + test("the exchange cannot hang forever", async () => { + stubBrowser(); + const { fetchMock, release } = stubExchange({ access_token: "session-token" }); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: "u1" }); + + const client = newClient(); + release(); + await client.auth.me(); + + const [, init] = fetchMock.mock.calls[0]; + expect((init as RequestInit).signal).toBeInstanceOf(AbortSignal); + }); + + // An embedded logout returns instead of navigating, so the page — and + // everything still holding the session — outlives it. + test("an embedded logout leaves nothing holding the session", async () => { + stubBrowser(); + stubDocument(); + const { release } = stubExchange({ access_token: "session-token" }); + nock(serverUrl).get(`/api/apps/${appId}/entities/User/me`).reply(200, { id: "u1" }); + + const client = newClient(); + release(); + await client.auth.me(); + client.entities.Todo.subscribe(() => {}); + const socket = vi.mocked(io).mock.results.at(-1)!.value; + + client.auth.logout(); + + expect(socket.disconnect).toHaveBeenCalled(); + const call = nock(serverUrl, { badheaders: ["authorization"] }) + .post(`/api/apps/${appId}/functions/report`) + .reply(200, {}); + await client.functions.invoke("report", {}); + expect(call.isDone()).toBe(true); + }); + + // Every module asks getToken() per use; the socket cannot — it carries the + // token on the handshake — so it has to be told, whichever setToken is used. + test("auth.setToken reaches the socket too", () => { + const localStorage = fakeStorage(); + localStorage.setItem("base44_access_token", "stored-token"); + stubBrowser({ url: "https://app.base44.app/orders", localStorage }); + stubExchange({ access_token: "unused" }); + + const client = newClient(); + client.entities.Todo.subscribe(() => {}); + client.auth.setToken("fresh-token", false); + + const [, options] = vi.mocked(io).mock.calls.at(-1)!; + expect((options as { query: Record }).query.token).toBe("fresh-token"); + expect(localStorage.setItem).not.toHaveBeenCalledWith("base44_access_token", "fresh-token"); + }); + +}); + +// The template's app-params reads getAccessToken() once as the page loads, +// before createClient runs, and the app gates its auth check on that snapshot. +describe("getAccessToken on an embedded load", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("returns the one-time token, without storing it or taking it off the URL", () => { + const localStorage = fakeStorage(); + const windowRef = stubBrowser({ localStorage }); + windowRef.location.search = "?ott=the-ott&tab=open"; + windowRef.location.pathname = "/orders"; + windowRef.location.hash = ""; + + expect(getAccessToken()).toBe("the-ott"); + expect(localStorage.setItem).not.toHaveBeenCalled(); + expect(windowRef.location.href).toBe("https://app.base44.app/orders?ott=the-ott&tab=open"); + }); + + test("reports nothing in a top-level tab, where no one redeems it", () => { + const localStorage = fakeStorage(); + localStorage.setItem("base44_access_token", "stored-token"); + const windowRef = stubBrowser({ framed: false, localStorage }); + windowRef.location.search = "?ott=the-ott&tab=open"; + windowRef.location.pathname = "/orders"; + windowRef.location.hash = ""; + + expect(getAccessToken()).toBe("stored-token"); + }); + + test("an explicit access token on the URL still wins", () => { + const windowRef = stubBrowser(); + windowRef.location.search = "?access_token=real-token&ott=the-ott"; + windowRef.location.pathname = "/orders"; + windowRef.location.hash = ""; + vi.stubGlobal("document", { title: "" }); + + expect(getAccessToken()).toBe("real-token"); + }); +}); diff --git a/tests/unit/embed-session.test.ts b/tests/unit/embed-session.test.ts new file mode 100644 index 00000000..f75a0412 --- /dev/null +++ b/tests/unit/embed-session.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + exchangeEmbedToken, + isFramed, + showEmbedSessionEnded, + takeEmbedTokenFromUrl, +} from "../../src/utils/embed-session.ts"; + +const fakeStorage = () => { + const values = new Map(); + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + }; +}; + +// `framed` puts the window inside a parent frame, as an embedded app is. +const fakeWindow = ({ + url, + sessionStorage = fakeStorage(), + framed = true, +}: { + url: string; + sessionStorage?: ReturnType | { getItem(): never; setItem(): never }; + framed?: boolean; +}) => { + const windowRef: any = { + location: { href: url }, + history: { + state: { router: "state" }, + replaceState: (state: unknown, _title: string, nextUrl: string) => { + windowRef.history.state = state; + windowRef.location.href = nextUrl; + }, + }, + sessionStorage, + }; + windowRef.self = windowRef; + windowRef.top = framed ? {} : windowRef; + return windowRef; +}; + +const okResponse = (body: unknown) => + ({ ok: true, status: 200, json: async () => body }) as unknown as Response; +const refusedResponse = (status = 400) => + ({ ok: false, status, json: async () => ({ error: "invalid_grant" }) }) as unknown as Response; + +// Answers with the responses in order, repeating the last one. +const countingFetch = (...responses: Response[]) => { + const calls: { url: string; init: RequestInit }[] = []; + const fetchImpl = (async (url: string, init: RequestInit) => { + calls.push({ url, init }); + return responses[Math.min(calls.length, responses.length) - 1]; + }) as unknown as typeof fetch & { calls: typeof calls }; + fetchImpl.calls = calls; + return fetchImpl; +}; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("takeEmbedTokenFromUrl", () => { + test("takes the one-time token off the URL and leaves the other params in place", () => { + const windowRef = fakeWindow({ url: "https://app.base44.app/orders?ott=the-ott&tab=open" }); + vi.stubGlobal("window", windowRef); + + expect(takeEmbedTokenFromUrl()).toBe("the-ott"); + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + expect(windowRef.history.state).toEqual({ router: "state" }); + }); + + // Dropping the token here would leave `?ott=` on the URL for getAccessToken + // to hand back, and an app would send a one-time token as a bearer. + test("still returns the token when the URL cannot be rewritten", () => { + const windowRef = fakeWindow({ url: "https://app.base44.app/orders?ott=the-ott" }); + windowRef.history.replaceState = () => { + throw new Error("SecurityError: replaceState is not available here"); + }; + vi.stubGlobal("window", windowRef); + + expect(takeEmbedTokenFromUrl()).toBe("the-ott"); + }); + + test("leaves a URL without a one-time token untouched", () => { + const windowRef = fakeWindow({ url: "https://app.base44.app/orders?tab=open" }); + vi.stubGlobal("window", windowRef); + + expect(takeEmbedTokenFromUrl()).toBeNull(); + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + }); + + test("returns null outside a browser", () => { + vi.stubGlobal("window", undefined); + expect(takeEmbedTokenFromUrl()).toBeNull(); + }); + + // Nobody redeems one in a top-level tab, so it is not reported there — but it + // still comes off the URL rather than sitting in the address bar. + test("strips the token but reports nothing in a top-level tab", () => { + const windowRef = fakeWindow({ + url: "https://app.base44.app/orders?ott=the-ott&tab=open", + framed: false, + }); + vi.stubGlobal("window", windowRef); + + expect(takeEmbedTokenFromUrl()).toBeNull(); + expect(windowRef.location.href).toBe("https://app.base44.app/orders?tab=open"); + }); +}); + +describe("isFramed", () => { + test("is true inside a frame", () => { + vi.stubGlobal("window", fakeWindow({ url: "https://app.base44.app/" })); + expect(isFramed()).toBe(true); + }); + + test("is false in a top-level tab", () => { + vi.stubGlobal("window", fakeWindow({ url: "https://app.base44.app/", framed: false })); + expect(isFramed()).toBe(false); + }); + + test("is false outside a browser", () => { + vi.stubGlobal("window", undefined); + expect(isFramed()).toBe(false); + }); + +}); + +describe("exchangeEmbedToken", () => { + const params = { serverUrl: "https://api.base44.test", appId: "app-1", ott: "the-ott" }; + + test("posts the RFC 8693 grant to the app's exchange endpoint", async () => { + const fetchImpl = countingFetch(okResponse({ access_token: "session-token", token_type: "Bearer" })); + + expect(await exchangeEmbedToken({ ...params, fetchImpl })).toBe("session-token"); + expect(fetchImpl.calls).toHaveLength(1); + expect(fetchImpl.calls[0].url).toBe("https://api.base44.test/api/apps/app-1/auth/embed/token"); + expect(fetchImpl.calls[0].init.method).toBe("POST"); + expect(Object.fromEntries(fetchImpl.calls[0].init.body as URLSearchParams)).toEqual({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: "the-ott", + subject_token_type: "urn:base44:params:oauth:token-type:embed-ott", + }); + }); + + test("resolves to null when the exchange is refused", async () => { + const fetchImpl = countingFetch(refusedResponse()); + expect(await exchangeEmbedToken({ ...params, fetchImpl })).toBeNull(); + expect(fetchImpl.calls).toHaveLength(1); + }); + + test("retries once when rate-limited, since the token is still valid", async () => { + vi.useFakeTimers(); + const fetchImpl = countingFetch(refusedResponse(429), okResponse({ access_token: "session-token" })); + + const pending = exchangeEmbedToken({ ...params, fetchImpl }); + await vi.advanceTimersByTimeAsync(1000); + + expect(await pending).toBe("session-token"); + expect(fetchImpl.calls).toHaveLength(2); + }); + + test("gives up after a second rate-limit response", async () => { + vi.useFakeTimers(); + const fetchImpl = countingFetch(refusedResponse(429)); + + const pending = exchangeEmbedToken({ ...params, fetchImpl }); + await vi.advanceTimersByTimeAsync(1000); + + expect(await pending).toBeNull(); + expect(fetchImpl.calls).toHaveLength(2); + }); + + test("resolves to null instead of throwing when the request fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchImpl = (async () => { + throw new Error("network down"); + }) as unknown as typeof fetch; + + expect(await exchangeEmbedToken({ ...params, fetchImpl })).toBeNull(); + }); +}); + +describe("showEmbedSessionEnded", () => { + const fakeDocument = () => { + const appended: any[] = []; + const elements: Record = {}; + const createElement = (tag: string) => { + const el: any = { tag, style: {}, children: [] as any[], attributes: {} as Record }; + el.setAttribute = (name: string, value: string) => (el.attributes[name] = value); + el.append = (...nodes: any[]) => el.children.push(...nodes); + Object.defineProperty(el, "id", { + set(value: string) { + elements[value] = el; + }, + }); + return el; + }; + return { + appended, + createElement, + getElementById: (id: string) => elements[id] ?? null, + body: { append: (...nodes: any[]) => appended.push(...nodes) }, + }; + }; + + test("covers the page with a session-ended notice, once", () => { + const documentRef = fakeDocument(); + vi.stubGlobal("document", documentRef); + + showEmbedSessionEnded(); + showEmbedSessionEnded(); + + expect(documentRef.appended).toHaveLength(1); + const [overlay] = documentRef.appended; + expect(overlay.attributes.role).toBe("alert"); + const card = overlay.children.at(-1); + const texts = card.children.map((c: any) => c.textContent); + expect(texts).toEqual([ + "Session ended", + "Reload this page in your browser to start a new session.", + ]); + }); + + // The app it covers can be in either theme, and the reader can switch while + // the notice is up, so the colors are a stylesheet rather than fixed values. + test("follows the reader's color scheme", () => { + const documentRef = fakeDocument(); + vi.stubGlobal("document", documentRef); + + showEmbedSessionEnded(); + + const [overlay] = documentRef.appended; + expect(overlay.style.cssText).toContain("background:var(--b44-bg)"); + const sheet = overlay.children.find((c: any) => c.tag === "style"); + expect(sheet.textContent).toContain("prefers-color-scheme:dark"); + }); + + test("does nothing outside a browser", () => { + vi.stubGlobal("document", undefined); + expect(() => showEmbedSessionEnded()).not.toThrow(); + }); +}); diff --git a/tests/unit/socket-utils.test.ts b/tests/unit/socket-utils.test.ts index 2d58f294..b0a76f7e 100644 --- a/tests/unit/socket-utils.test.ts +++ b/tests/unit/socket-utils.test.ts @@ -20,10 +20,6 @@ vi.mock("socket.io-client", () => ({ })), })); -vi.mock("../../src/utils/auth-utils.ts", () => ({ - getAccessToken: vi.fn(() => undefined), -})); - vi.mock("../../src/modules/analytics.ts", () => ({ getAnalyticsSessionId: vi.fn(() => "anon-session-123"), })); @@ -56,21 +52,33 @@ describe("RoomsSocket", () => { } test("sends a stable anonymous_id and no token when unauthenticated", () => { - RoomsSocket({ config: { ...baseConfig } }); + RoomsSocket({ config: { ...baseConfig, getToken: () => null } }); const query = lastHandshakeQuery(); expect(query.app_id).toBe("test-app-id"); expect(query.anonymous_id).toBe("anon-session-123"); - expect(query.token).toBeUndefined(); + expect(query.token).toBeNull(); }); test("sends the token and no anonymous_id when authenticated", () => { - RoomsSocket({ config: { ...baseConfig, token: "test-token" } }); + RoomsSocket({ config: { ...baseConfig, getToken: () => "test-token" } }); const query = lastHandshakeQuery(); expect(query.token).toBe("test-token"); expect(query.anonymous_id).toBeUndefined(); }); + + test("asks for the token again on every reconnect", () => { + let token: string | null = null; + const socket = RoomsSocket({ config: { ...baseConfig, getToken: () => token } }); + expect(lastHandshakeQuery().token).toBeNull(); + + token = "next-token"; + socket.reconnect(); + + expect(lastHandshakeQuery().token).toBe("next-token"); + expect(lastHandshakeQuery().anonymous_id).toBeUndefined(); + }); }); function createRoomsSocket() { @@ -80,7 +88,7 @@ describe("RoomsSocket", () => { mountPath: "/socket.io/", transports: ["websocket"], appId: "test-app-id", - token: "test-token", + getToken: () => "test-token", }, }); } @@ -166,7 +174,7 @@ describe("RoomsSocket", () => { const unsubscribe = socket.subscribeToRoom("room-a", {}); unsubscribe(); - socket.updateConfig({ token: "next-token" }); + socket.reconnect(); socket.subscribeToRoom("room-a", {}); vi.advanceTimersByTime(250);