From 66ad29e7b54f5dcbd36991ccfbecf2bffaf59993 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Tue, 15 Sep 2026 18:43:10 +0300 Subject: [PATCH] feat(client): redeem a platform embed token at createClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apps embedded by a host platform arrive with a one-time token on the iframe URL, `?ott=`, minted by the platform's server. `createClient` now takes it off the URL as the client is created, trades it for an app-user session through the OAuth token-exchange grant (RFC 8693, `POST /api/apps/{appId}/auth/embed/token`), and seeds every module with the result — in memory only. The session lives and dies with the frame. Doing this in the SDK rather than in the app template is what makes it reach every existing app: the platform bumps `sdk_min_version` and its sandbox codemod pulls the new version in on each app's next boot. The exchange - The token is stripped from the URL before any other code can read `location.href`, and reported back only inside a frame — the only place one is redeemed. A top-level load still has it taken off the address bar, history and any shared link. - `authReady` gates everything that can wait: an axios request interceptor on the two user clients, `functions.fetch`, `fetchWithAuth`, and the actors mint and dial. It never rejects, so one failed exchange does not become a rejection on every request. The POST aborts after 15s rather than leaving every request parked behind it. - A 429 is retried once: the server's limiter refuses before the token is redeemed, so it is still valid. - An embedded load never reads a stored token. Whatever an earlier visitor left in `localStorage` is not this session. One live token `getToken()` replaces the seven `token || getAccessToken()` closures (actors auth and mint, `functions.fetch`, agents URLs, aiGateway, socket). Each had captured a construction-time token and could otherwise only fall back to storage — so a session held in memory reached none of them. The auth module now owns the current token and reports every change through `onSessionChange`, which is how the realtime socket learns to redial, and how `logout` drops a connection still running as the user who just left. - `base44.auth.isEmbedded()` — public: true when a platform embedded this load. The session is memory-only, so a reload inside the frame ends it and this returns false there. - `redirectToLogin()` and `logout()` in a frame show a "session ended" notice instead of navigating. Only the platform can mint the next session; the app's own login would work in the frame but would mint a different, app-level identity. - `logout()` now clears `Authorization` on both axios instances. Missing the functions one was masked by the redirect tearing the page down. - The socket takes `getToken` and asks on every connect, replacing a static token it had to be handed. - `getAccessToken()` returns the `?ott=` value on an embedded load — not stored, not stripped. Every app's `app-params.js` calls it once before `createClient` runs, and `AuthContext` gates its auth check on that snapshot, so without it an embedded app renders signed out while every request it makes is authenticated. Backward compatibility An app opened normally is unaffected: with no `?ott=`, `createClient` runs the code it ran before, and `getToken()` still falls back to storage so a login in another tab is picked up on reconnect. `AgentsModuleConfig` and `AiGatewayModuleConfig` (both `@internal`) take `getToken`; `createClient` is their only caller. Tests `tests/unit/embed-session.test.ts` and `tests/unit/client-embed.test.ts` cover the URL strip, the frame-only report, the RFC 8693 request shape, refused / thrown / 429-retried / timed-out exchanges, the notice overlay, and through `createClient`: a request issued mid-exchange carrying the exchanged bearer, nothing written to storage, a stale stored token ignored, the socket connecting with the exchanged session, and a normal app left untouched. `npm test` and `eslint` pass on Node 26. Server side ships in base44-dev/apper#24167; this supersedes base44-dev/apper#24259. Co-Authored-By: Claude Opus 5 --- src/client.ts | 105 +++++-- src/modules/actors.ts | 7 +- src/modules/agents.ts | 7 +- src/modules/agents.types.ts | 12 +- src/modules/ai-gateway.ts | 5 +- src/modules/ai-gateway.types.ts | 4 +- src/modules/auth.ts | 53 +++- src/modules/auth.types.ts | 29 ++ src/modules/functions.ts | 3 + src/modules/functions.types.ts | 6 + src/utils/auth-utils.ts | 24 +- src/utils/embed-session.ts | 133 ++++++++ src/utils/fetch-with-auth.ts | 6 + src/utils/socket-utils.ts | 17 +- tests/unit/actors.test.ts | 4 +- tests/unit/client-embed.test.ts | 510 +++++++++++++++++++++++++++++++ tests/unit/embed-session.test.ts | 245 +++++++++++++++ tests/unit/socket-utils.test.ts | 26 +- 18 files changed, 1127 insertions(+), 69 deletions(-) create mode 100644 src/utils/embed-session.ts create mode 100644 tests/unit/client-embed.test.ts create mode 100644 tests/unit/embed-session.test.ts 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);