@base44-preview/sdk 0.8.42-pr.256.fb681e4 → 0.8.43-pr.244.b43fd83

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -5,6 +5,7 @@ import { createAuthModule } from "./modules/auth.js";
5
5
  import { createSsoModule } from "./modules/sso.js";
6
6
  import { createConnectorsModule, createUserConnectorsModule, } from "./modules/connectors.js";
7
7
  import { getAccessToken } from "./utils/auth-utils.js";
8
+ import { redeemSessionHandoffCode } from "./utils/session-handoff.js";
8
9
  import { createFunctionsModule } from "./modules/functions.js";
9
10
  import { createAgentsModule } from "./modules/agents.js";
10
11
  import { createAiGatewayModule } from "./modules/ai-gateway.js";
@@ -113,15 +114,38 @@ export function createClient(config) {
113
114
  const userAuthModule = createAuthModule(axiosClient, functionsAxiosClient, appId, {
114
115
  appBaseUrl: normalizedAppBaseUrl,
115
116
  serverUrl,
117
+ token,
116
118
  });
117
119
  // Apply the access token before any module that may issue authenticated
118
120
  // requests during construction (notably analytics, which fires an init
119
121
  // event whose flush calls auth.me()). Without this, the first User/me
120
122
  // request is built before setToken runs and goes out unauthenticated.
123
+ //
124
+ // Precedence: an explicit config token wins (legacy behavior); then a PKCE
125
+ // session-code handoff in the URL (base44-dev/apper#17216 §5.2) — the user
126
+ // just completed a login, so it outranks any stored token, mirroring how
127
+ // getAccessToken prefers a URL access_token over localStorage; then the
128
+ // legacy capture. When the server spoke legacy — including after a backend
129
+ // rollback — redeemSessionHandoffCode() returns null synchronously and the
130
+ // legacy capture below runs unchanged.
131
+ let tokenBootstrap = null;
121
132
  if (typeof window !== "undefined") {
122
- const accessToken = token || getAccessToken();
123
- if (accessToken) {
124
- userAuthModule.setToken(accessToken);
133
+ const sessionExchange = token ? null : redeemSessionHandoffCode();
134
+ if (sessionExchange) {
135
+ tokenBootstrap = sessionExchange.then((exchangedToken) => {
136
+ // On exchange failure, fall back to any stored token rather than
137
+ // leaving auth state empty (same fallback getAccessToken applies).
138
+ const accessToken = exchangedToken || getAccessToken();
139
+ if (accessToken) {
140
+ userAuthModule.setToken(accessToken);
141
+ }
142
+ });
143
+ }
144
+ else {
145
+ const accessToken = token || getAccessToken();
146
+ if (accessToken) {
147
+ userAuthModule.setToken(accessToken);
148
+ }
125
149
  }
126
150
  }
127
151
  const actorsModule = createActorsModule({
@@ -218,6 +242,13 @@ export function createClient(config) {
218
242
  // We perform this check asynchronously to not block client creation
219
243
  setTimeout(async () => {
220
244
  try {
245
+ // A pending session-code exchange must settle before the auth probe.
246
+ // Probing early would see no token, redirect to login, and abandon
247
+ // the in-flight exchange — minting a fresh code on every round, i.e.
248
+ // a login loop (the exact BUG-787 failure shape).
249
+ if (tokenBootstrap) {
250
+ await tokenBootstrap;
251
+ }
221
252
  const isAuthenticated = await userModules.auth.isAuthenticated();
222
253
  if (!isAuthenticated) {
223
254
  userModules.auth.redirectToLogin(window.location.href);
package/dist/index.d.ts CHANGED
@@ -14,6 +14,6 @@ export type { AppLogsModule } from "./modules/app-logs.types.js";
14
14
  export type { ActorsModule, ActorClient, ActorRef, Connection, ActorSubscription, ActorConnectOptions, ActorNameRegistry, ActorRegistry, } from "./modules/actors.types.js";
15
15
  export type { SsoModule, SsoAccessTokenResponse } from "./modules/sso.types.js";
16
16
  export { Actor, type Conn } from "./actor.js";
17
- export type { ConnectorsModule, UserConnectorsModule, ConnectorApiRequest, ConnectorApiResponse, } from "./modules/connectors.types.js";
17
+ export type { ConnectorsModule, UserConnectorsModule, } from "./modules/connectors.types.js";
18
18
  export type { CustomIntegrationsModule, CustomIntegrationCallParams, CustomIntegrationCallResponse, } from "./modules/custom-integrations.types.js";
19
19
  export type { GetAccessTokenOptions, SaveAccessTokenOptions, RemoveAccessTokenOptions, GetLoginUrlOptions, } from "./utils/auth-utils.types.js";
@@ -1,6 +1,6 @@
1
1
  import { AxiosInstance } from "axios";
2
2
  import { TrackEventParams, AnalyticsModuleOptions } from "./analytics.types";
3
- import type { AuthModule } from "./auth.types";
3
+ import type { InternalAuthModule } from "./auth.types";
4
4
  export declare const USER_HEARTBEAT_EVENT_NAME = "__user_heartbeat_event__";
5
5
  export declare const ANALYTICS_INITIALIZATION_EVENT_NAME = "__initialization_event__";
6
6
  export declare const ANALYTICS_SESSION_DURATION_EVENT_NAME = "__session_duration_event__";
@@ -10,7 +10,7 @@ export interface AnalyticsModuleArgs {
10
10
  axiosClient: AxiosInstance;
11
11
  serverUrl: string;
12
12
  appId: string;
13
- userAuthModule: AuthModule;
13
+ userAuthModule: InternalAuthModule;
14
14
  }
15
15
  export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, userAuthModule, }: AnalyticsModuleArgs) => {
16
16
  track: (params: TrackEventParams) => void;
@@ -247,6 +247,13 @@ export function resetAnalyticsSessionContext() {
247
247
  }
248
248
  async function getSessionContext(userAuthModule) {
249
249
  if (!analyticsSharedState.sessionContext) {
250
+ // With no token there is no identity to resolve: `me()` can only answer 401,
251
+ // which the browser logs to the console before any handler here sees it. On
252
+ // a public page that request is the sole reason an error appears, so skip
253
+ // it. This is not memoized — a visitor who logs in later must still resolve.
254
+ if (!userAuthModule.hasToken()) {
255
+ return { user_id: null, session_id: getAnalyticsSessionId() };
256
+ }
250
257
  if (!sessionContextPromise) {
251
258
  const sessionId = getAnalyticsSessionId();
252
259
  sessionContextPromise = userAuthModule
@@ -1,5 +1,5 @@
1
1
  import { AxiosInstance } from "axios";
2
- import { AuthModule, AuthModuleOptions } from "./auth.types";
2
+ import { AuthModuleOptions, InternalAuthModule } from "./auth.types";
3
3
  /**
4
4
  * Creates the auth module for the Base44 SDK.
5
5
  *
@@ -10,4 +10,4 @@ import { AuthModule, AuthModuleOptions } from "./auth.types";
10
10
  * @returns Auth module with authentication and user management methods
11
11
  * @internal
12
12
  */
13
- export declare function createAuthModule(axios: AxiosInstance, functionsAxiosClient: AxiosInstance, appId: string, options: AuthModuleOptions): AuthModule;
13
+ export declare function createAuthModule(axios: AxiosInstance, functionsAxiosClient: AxiosInstance, appId: string, options: AuthModuleOptions): InternalAuthModule;
@@ -1,4 +1,5 @@
1
1
  import { resetAnalyticsSessionContext } from "./analytics.js";
2
+ import { prepareSessionHandoffKickoff } from "../utils/session-handoff.js";
2
3
  function isInsideIframe() {
3
4
  if (typeof window === "undefined")
4
5
  return false;
@@ -78,7 +79,14 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
78
79
  const clearPendingMe = () => {
79
80
  pendingMe = null;
80
81
  };
82
+ // Tracked here rather than read off `axios.defaults` so the answer stays tied
83
+ // to the identity transitions below (`setToken`, `logout`) instead of to the
84
+ // header a caller may have set on the instance directly.
85
+ let hasAccessToken = Boolean(options.token);
81
86
  return {
87
+ hasToken() {
88
+ return hasAccessToken;
89
+ },
82
90
  // Get current user information
83
91
  async me() {
84
92
  const request = pendingMe !== null && pendingMe !== void 0 ? pendingMe : axios.get(`/apps/${appId}/entities/User/me`).finally(() => {
@@ -128,11 +136,32 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
128
136
  }
129
137
  const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`;
130
138
  // When running inside an iframe, use a popup to avoid OAuth providers
131
- // blocking iframe navigation.
139
+ // blocking iframe navigation. Popups stay on the exact legacy kickoff:
140
+ // they deliver the token via postMessage and never redeem a code (the
141
+ // backend skips the code mint when popup_origin is present).
132
142
  if (isInsideIframe()) {
133
143
  const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
134
144
  return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
135
145
  }
146
+ // Full-page SSO redirect: offer the PKCE session-code handoff
147
+ // (base44-dev/apper#17216 §5.2). The backend decides per request whether
148
+ // to use it; a server that answers with the legacy ?access_token= —
149
+ // including after a backend rollback — is honored unchanged at
150
+ // redemption. If PKCE can't be prepared locally, kick off with the
151
+ // unmodified legacy URL (version=2 without a valid challenge is a 400
152
+ // at /login, so it's all-or-nothing).
153
+ if (provider === "sso") {
154
+ prepareSessionHandoffKickoff()
155
+ .then((pkceQuery) => {
156
+ window.location.href = pkceQuery
157
+ ? `${loginUrl}${pkceQuery}`
158
+ : loginUrl;
159
+ })
160
+ .catch(() => {
161
+ window.location.href = loginUrl;
162
+ });
163
+ return;
164
+ }
136
165
  // Default: full-page redirect
137
166
  window.location.href = loginUrl;
138
167
  },
@@ -144,6 +173,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
144
173
  // flight would otherwise resolve into callers that run after the logout.
145
174
  clearPendingMe();
146
175
  resetAnalyticsSessionContext();
176
+ hasAccessToken = false;
147
177
  // Only do the rest if in a browser environment
148
178
  if (typeof window !== "undefined") {
149
179
  // Remove token from localStorage
@@ -172,6 +202,7 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
172
202
  // resolved for the previous one must not be handed to later callers.
173
203
  clearPendingMe();
174
204
  resetAnalyticsSessionContext();
205
+ hasAccessToken = true;
175
206
  // handle token change for axios clients
176
207
  axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
177
208
  functionsAxiosClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
@@ -92,6 +92,12 @@ export interface AuthModuleOptions {
92
92
  serverUrl: string;
93
93
  /** Base URL for the app (used for login redirects). */
94
94
  appBaseUrl: string;
95
+ /**
96
+ * Access token the client was constructed with, if any. Seeds the module's
97
+ * view of whether a session exists before {@link AuthModule.setToken} runs,
98
+ * which is how the server-side SDK reports a token it never sets explicitly.
99
+ */
100
+ token?: string;
95
101
  }
96
102
  /**
97
103
  * Authentication module for managing user authentication and authorization. The module automatically stores tokens in local storage when available and manages authorization headers for API requests.
@@ -515,3 +521,20 @@ export interface AuthModule {
515
521
  */
516
522
  changePassword(params: ChangePasswordParams): Promise<any>;
517
523
  }
524
+ /**
525
+ * The auth module as constructed internally, before it is narrowed to
526
+ * {@link AuthModule} on the public client. Not exported from the package
527
+ * index — SDK consumers see only {@link AuthModule}.
528
+ *
529
+ * @internal
530
+ */
531
+ export interface InternalAuthModule extends AuthModule {
532
+ /**
533
+ * Whether an access token is currently set on the client.
534
+ *
535
+ * Reports only the presence of a token, never its validity — an expired or
536
+ * revoked token still reads as `true`. Callers use this to skip requests that
537
+ * could not succeed without a session, not to decide that one is valid.
538
+ */
539
+ hasToken(): boolean;
540
+ }
@@ -68,46 +68,6 @@ export function createConnectorsModule(axios, appId) {
68
68
  connectionConfig: (_a = data.connection_config) !== null && _a !== void 0 ? _a : null,
69
69
  };
70
70
  },
71
- async callApi(integrationType, request) {
72
- assertNonEmptyString(integrationType, "Integration type");
73
- return proxyCall(axios, `/apps/${appId}/connectors/${integrationType}/call`, request);
74
- },
75
- };
76
- }
77
- function assertNonEmptyString(value, label) {
78
- if (!value || typeof value !== "string") {
79
- throw new Error(`${label} is required and must be a string`);
80
- }
81
- }
82
- /**
83
- * POST a request to the connector proxy and normalize the response.
84
- *
85
- * The proxy reports upstream outcomes in the body rather than as HTTP status, so
86
- * a provider 4xx/5xx arrives here as a resolved response with `success: false` —
87
- * only Base44-side failures reject through the axios error interceptor.
88
- *
89
- * @internal
90
- */
91
- async function proxyCall(axios, url, request) {
92
- var _a, _b, _c, _d, _e, _f, _g;
93
- if (!request || typeof request !== "object") {
94
- throw new Error("Request is required and must be an object");
95
- }
96
- assertNonEmptyString(request.path, "Request path");
97
- const response = await axios.post(url, {
98
- method: ((_a = request.method) !== null && _a !== void 0 ? _a : "GET").toUpperCase(),
99
- path: request.path,
100
- query: (_b = request.query) !== null && _b !== void 0 ? _b : {},
101
- headers: (_c = request.headers) !== null && _c !== void 0 ? _c : {},
102
- body: (_d = request.body) !== null && _d !== void 0 ? _d : null,
103
- });
104
- const data = response;
105
- return {
106
- success: data.success,
107
- status: (_e = data.status_code) !== null && _e !== void 0 ? _e : null,
108
- data: data.data,
109
- headers: (_f = data.headers) !== null && _f !== void 0 ? _f : {},
110
- creditsCharged: (_g = data.credits_charged) !== null && _g !== void 0 ? _g : 0,
111
71
  };
112
72
  }
113
73
  /**
@@ -41,52 +41,6 @@ export interface AppUserConnectorConnectionResponse {
41
41
  /** Key-value configuration for the connection, or `null` if the connector does not provide one. */
42
42
  connectionConfig: Record<string, string> | null;
43
43
  }
44
- /**
45
- * A request to forward to a metered connector's API through the Base44 proxy.
46
- */
47
- export interface ConnectorApiRequest {
48
- /** HTTP method for the upstream request. Defaults to `'GET'`. */
49
- method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
50
- /**
51
- * Path relative to the connector's API root, starting with `/`, such as `'/2/tweets'`.
52
- *
53
- * Must not be an absolute URL. Query parameters may be included here or passed
54
- * separately as {@link query}; either way they are forwarded and priced identically.
55
- */
56
- path: string;
57
- /** Query parameters. Merged into the request URL alongside any already present in {@link path}. */
58
- query?: Record<string, string | number | boolean | Array<string | number>>;
59
- /** Extra request headers. Only headers the connector explicitly allows are forwarded; the rest are dropped. */
60
- headers?: Record<string, string>;
61
- /** JSON request body. Ignored for `GET`, `HEAD`, and `DELETE`. */
62
- body?: unknown;
63
- }
64
- /**
65
- * The upstream API's response, as returned by the Base44 connector proxy.
66
- */
67
- export interface ConnectorApiResponse<T = unknown> {
68
- /** `true` when the upstream API returned a 2xx status. */
69
- success: boolean;
70
- /** The upstream HTTP status code, or `null` if the request never reached the provider. */
71
- status: number | null;
72
- /** The parsed upstream response body. */
73
- data: T;
74
- /** The subset of upstream response headers the connector exposes, typically rate-limit counters. */
75
- headers: Record<string, string>;
76
- /** Integration credits billed to the workspace for this call. */
77
- creditsCharged: number;
78
- }
79
- /**
80
- * Raw proxy response shape. Mapped to {@link ConnectorApiResponse} before being returned.
81
- * @internal
82
- */
83
- export interface ConnectorProxyRawResponse {
84
- success: boolean;
85
- status_code: number | null;
86
- data: unknown;
87
- headers: Record<string, string>;
88
- credits_charged: number;
89
- }
90
44
  /**
91
45
  * Connectors module for managing OAuth tokens for external services.
92
46
  *
@@ -117,17 +71,6 @@ export interface ConnectorProxyRawResponse {
117
71
  * 3. In a backend function, call {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} using the service role client (`base44.asServiceRole.connectors`) with the connector ID to retrieve the app user's token.
118
72
  * 4. Use the returned `accessToken` to call the external service's API directly. Some connectors also return a `connectionConfig` with additional values such as a subdomain for building the API URL.
119
73
  *
120
- * ## Metered connectors
121
- *
122
- * A few [platform connectors](#shared-connectors) are backed by paid third-party APIs that charge Base44 per call. For those, the OAuth token is **not** available to your code — {@linkcode getConnection | getConnection()} rejects with a `403`. Call them with {@linkcode callApi | callApi()} instead: Base44 attaches the credential server-side, forwards the request, and bills your workspace's integration credits for the call.
123
- *
124
- * This applies to platform connectors only. A workspace-registered or app user connector runs on **your own** OAuth app, so the provider invoices you directly and there is nothing for Base44 to meter — those keep normal token access via {@linkcode getWorkspaceConnection | getWorkspaceConnection()} and {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()}.
125
- *
126
- * Two things to keep in mind when writing against a metered connector:
127
- *
128
- * - **Cost varies by endpoint, sometimes sharply.** The same connector can charge two orders of magnitude more for one endpoint than another, so avoid putting an expensive call inside a loop and batch wherever the provider supports it. Each response reports what it actually cost as `creditsCharged`.
129
- * - **An upstream error is returned, not thrown.** A `4xx` or `5xx` from the provider comes back as `success: false` with the provider's own `status` and `data`, because it is a normal outcome of a call that Base44 completed. Only Base44-side failures — no connection, credits exhausted, a rejected request — reject the promise.
130
- *
131
74
  * ## Available connectors
132
75
  *
133
76
  * The connectors below can be used as shared connectors or as app user connectors. For a shared platform connector, pass the integration type string to {@linkcode getConnection | getConnection()}. For a connector you register in Workspace Settings with your own OAuth app, use the connector ID with {@linkcode getWorkspaceConnection | getWorkspaceConnection()} for a shared token, or with {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()} for a per-user token.
@@ -385,41 +328,6 @@ export interface ConnectorsModule {
385
328
  * ```
386
329
  */
387
330
  getCurrentAppUserConnection(connectorId: string): Promise<AppUserConnectorConnectionResponse>;
388
- /**
389
- * Calls a [metered connector's](#metered-connectors) API through the Base44 proxy.
390
- *
391
- * Use this for a shared platform connector identified by an integration type. Base44 adds the OAuth credential to the outgoing request, forwards it, and bills the workspace for the call, so you never handle the token yourself.
392
- *
393
- * @param integrationType - The type of integration, such as `'x'`. See [Available connectors](#available-connectors).
394
- * @param request - The upstream request to forward. See {@link ConnectorApiRequest}.
395
- * @returns Promise resolving to a {@link ConnectorApiResponse}. Note that an upstream error is reported in `success` and `status`, not thrown — only Base44-side failures reject.
396
- *
397
- * @example
398
- * ```typescript
399
- * // Post to X
400
- * const res = await base44.asServiceRole.connectors.callApi('x', {
401
- * method: 'POST',
402
- * path: '/2/tweets',
403
- * body: { text: 'Shipped!' },
404
- * });
405
- *
406
- * if (!res.success) {
407
- * console.error('X rejected the post', res.status, res.data);
408
- * }
409
- * ```
410
- *
411
- * @example
412
- * ```typescript
413
- * // Read, with query parameters and a look at what the call cost
414
- * const res = await base44.asServiceRole.connectors.callApi('x', {
415
- * path: '/2/tweets/search/recent',
416
- * query: { query: 'base44', max_results: 10 },
417
- * });
418
- *
419
- * console.log(`${res.creditsCharged} credits`, res.data);
420
- * ```
421
- */
422
- callApi<T = unknown>(integrationType: ConnectorIntegrationType, request: ConnectorApiRequest): Promise<ConnectorApiResponse<T>>;
423
331
  }
424
332
  /**
425
333
  * User-scoped connectors module for managing app user OAuth connections.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * PKCE-bound one-time session-code handoff for SSO logins
3
+ * (base44-dev/apper#17216 §5.2).
4
+ *
5
+ * The SDK OFFERS the handoff at login kickoff (`version=2` + S256
6
+ * `code_challenge`) and the backend DECIDES per request: it only emits a
7
+ * `session_code` when every server-side gate holds (verified custom domain,
8
+ * non-private app, feature flag ON). In every other case — older backends,
9
+ * excluded apps, and critically a BACKEND ROLLBACK — the server keeps
10
+ * delivering the legacy `?access_token=` URL param, which the SDK digests
11
+ * exactly as before. This is a negotiation, never a deprecation: the legacy
12
+ * path must keep working here forever.
13
+ *
14
+ * Fail-open rule for kickoff: never send `version=2` unless this browser can
15
+ * actually complete the exchange (WebCrypto, sessionStorage that persists,
16
+ * fetch). The server 400s a `version=2` login without a valid challenge, and
17
+ * an opted-in login whose verifier is lost can never redeem its code — both
18
+ * are avoided by simply not opting in and letting the legacy path run.
19
+ */
20
+ /** sessionStorage key for the PKCE verifier. Per-tab by design (RFC 7636: the
21
+ * verifier never leaves the browser); a login that completes in a different
22
+ * tab loses it — a named, expected failure mode, see redeemSessionHandoffCode. */
23
+ export declare const PKCE_VERIFIER_STORAGE_KEY = "base44_pkce_verifier";
24
+ /**
25
+ * Prepares the PKCE opt-in for an SSO login kickoff.
26
+ *
27
+ * Generates a verifier, persists it in sessionStorage (verified by read-back —
28
+ * a write that doesn't stick means the exchange could never succeed), and
29
+ * returns the query-string suffix to append to the login URL:
30
+ * `&version=2&code_challenge=<S256>&code_challenge_method=S256`.
31
+ *
32
+ * Returns `null` on ANY failure or missing capability, in which case the
33
+ * caller must use the unmodified legacy login URL. Never throws.
34
+ *
35
+ * @internal
36
+ */
37
+ export declare function prepareSessionHandoffKickoff(): Promise<string | null>;
38
+ /**
39
+ * Redeems a PKCE session-code handoff from the current URL, if one is present.
40
+ *
41
+ * Returns `null` synchronously when the URL carries no handoff — including
42
+ * when it carries a legacy `?access_token=` (the legacy capture wins outright;
43
+ * this is what makes a backend rollback safe). Otherwise strips the handoff
44
+ * params from the URL immediately and returns a promise resolving to the
45
+ * exchanged access token, or `null` when the exchange fails. Never rejects,
46
+ * never redirects: a failed exchange leaves the app unauthenticated and lets
47
+ * its normal login flow take over (each retry mints a fresh code, so this
48
+ * self-heals rather than looping).
49
+ *
50
+ * @internal
51
+ */
52
+ export declare function redeemSessionHandoffCode(): Promise<string | null> | null;
@@ -0,0 +1,184 @@
1
+ /**
2
+ * PKCE-bound one-time session-code handoff for SSO logins
3
+ * (base44-dev/apper#17216 §5.2).
4
+ *
5
+ * The SDK OFFERS the handoff at login kickoff (`version=2` + S256
6
+ * `code_challenge`) and the backend DECIDES per request: it only emits a
7
+ * `session_code` when every server-side gate holds (verified custom domain,
8
+ * non-private app, feature flag ON). In every other case — older backends,
9
+ * excluded apps, and critically a BACKEND ROLLBACK — the server keeps
10
+ * delivering the legacy `?access_token=` URL param, which the SDK digests
11
+ * exactly as before. This is a negotiation, never a deprecation: the legacy
12
+ * path must keep working here forever.
13
+ *
14
+ * Fail-open rule for kickoff: never send `version=2` unless this browser can
15
+ * actually complete the exchange (WebCrypto, sessionStorage that persists,
16
+ * fetch). The server 400s a `version=2` login without a valid challenge, and
17
+ * an opted-in login whose verifier is lost can never redeem its code — both
18
+ * are avoided by simply not opting in and letting the legacy path run.
19
+ */
20
+ /** sessionStorage key for the PKCE verifier. Per-tab by design (RFC 7636: the
21
+ * verifier never leaves the browser); a login that completes in a different
22
+ * tab loses it — a named, expected failure mode, see redeemSessionHandoffCode. */
23
+ export const PKCE_VERIFIER_STORAGE_KEY = "base44_pkce_verifier";
24
+ /** Server-side format for challenge/verifier: base64url of 32 bytes, 43 chars. */
25
+ const BASE64URL_43 = /^[A-Za-z0-9_-]{43}$/;
26
+ function base64UrlEncode(bytes) {
27
+ let binary = "";
28
+ for (const byte of bytes) {
29
+ binary += String.fromCharCode(byte);
30
+ }
31
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
32
+ }
33
+ /**
34
+ * Prepares the PKCE opt-in for an SSO login kickoff.
35
+ *
36
+ * Generates a verifier, persists it in sessionStorage (verified by read-back —
37
+ * a write that doesn't stick means the exchange could never succeed), and
38
+ * returns the query-string suffix to append to the login URL:
39
+ * `&version=2&code_challenge=<S256>&code_challenge_method=S256`.
40
+ *
41
+ * Returns `null` on ANY failure or missing capability, in which case the
42
+ * caller must use the unmodified legacy login URL. Never throws.
43
+ *
44
+ * @internal
45
+ */
46
+ export async function prepareSessionHandoffKickoff() {
47
+ var _a;
48
+ try {
49
+ if (typeof window === "undefined")
50
+ return null;
51
+ const crypto = globalThis.crypto;
52
+ if (!(crypto === null || crypto === void 0 ? void 0 : crypto.getRandomValues) || !((_a = crypto.subtle) === null || _a === void 0 ? void 0 : _a.digest))
53
+ return null;
54
+ // The exchange at redemption time needs fetch; don't opt in without it.
55
+ if (typeof fetch !== "function")
56
+ return null;
57
+ const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
58
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
59
+ const challenge = base64UrlEncode(new Uint8Array(digest));
60
+ // The server rejects a malformed opt-in with a 400 at /login; a malformed
61
+ // challenge here must therefore mean "don't opt in", never "send anyway".
62
+ if (!BASE64URL_43.test(challenge))
63
+ return null;
64
+ // Store last, after everything else succeeded, and verify the write took
65
+ // (sandboxed iframes and lockdown modes can throw OR silently drop it).
66
+ window.sessionStorage.setItem(PKCE_VERIFIER_STORAGE_KEY, verifier);
67
+ if (window.sessionStorage.getItem(PKCE_VERIFIER_STORAGE_KEY) !== verifier) {
68
+ return null;
69
+ }
70
+ return `&version=2&code_challenge=${challenge}&code_challenge_method=S256`;
71
+ }
72
+ catch (_b) {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Redeems a PKCE session-code handoff from the current URL, if one is present.
78
+ *
79
+ * Returns `null` synchronously when the URL carries no handoff — including
80
+ * when it carries a legacy `?access_token=` (the legacy capture wins outright;
81
+ * this is what makes a backend rollback safe). Otherwise strips the handoff
82
+ * params from the URL immediately and returns a promise resolving to the
83
+ * exchanged access token, or `null` when the exchange fails. Never rejects,
84
+ * never redirects: a failed exchange leaves the app unauthenticated and lets
85
+ * its normal login flow take over (each retry mints a fresh code, so this
86
+ * self-heals rather than looping).
87
+ *
88
+ * @internal
89
+ */
90
+ export function redeemSessionHandoffCode() {
91
+ if (typeof window === "undefined" || !window.location)
92
+ return null;
93
+ let code = null;
94
+ let exchangePath = null;
95
+ let urlParams;
96
+ try {
97
+ urlParams = new URLSearchParams(window.location.search);
98
+ code = urlParams.get("session_code");
99
+ exchangePath = urlParams.get("session_exchange_path");
100
+ if (!code || !exchangePath)
101
+ return null;
102
+ // A server speaking legacy is authoritative: if an access_token is in the
103
+ // URL (the two are never both sent by a real backend), take the legacy
104
+ // path and ignore the code entirely.
105
+ if (urlParams.get("access_token"))
106
+ return null;
107
+ // Strip the one-time params right away so the code doesn't linger in the
108
+ // URL/history or get re-submitted on reload. `is_new_user` stays in the
109
+ // URL exactly as the legacy flow leaves it.
110
+ urlParams.delete("session_code");
111
+ urlParams.delete("session_exchange_path");
112
+ const newUrl = `${window.location.pathname}${urlParams.toString() ? `?${urlParams.toString()}` : ""}${window.location.hash}`;
113
+ window.history.replaceState({}, typeof document !== "undefined" ? document.title : "", newUrl);
114
+ }
115
+ catch (e) {
116
+ console.error("Error reading session handoff params from URL:", e);
117
+ return null;
118
+ }
119
+ return exchangeSessionHandoffCode(code, exchangePath);
120
+ }
121
+ async function exchangeSessionHandoffCode(code, exchangePath) {
122
+ // The verifier is one-shot: take it out of storage no matter how the
123
+ // exchange ends (a failed PKCE check doesn't burn the code server-side,
124
+ // but a stale verifier can never match a future login's challenge).
125
+ let verifier = null;
126
+ try {
127
+ verifier = window.sessionStorage.getItem(PKCE_VERIFIER_STORAGE_KEY);
128
+ if (verifier !== null) {
129
+ window.sessionStorage.removeItem(PKCE_VERIFIER_STORAGE_KEY);
130
+ }
131
+ }
132
+ catch (_a) {
133
+ verifier = null;
134
+ }
135
+ // SECURITY: the exchange path arrives via the URL, so treat it as tainted.
136
+ // POSTing the code + verifier to an attacker-chosen origin would hand over
137
+ // both halves of the PKCE proof — enforce same-origin, path-only semantics.
138
+ let exchangeUrl;
139
+ try {
140
+ exchangeUrl = new URL(exchangePath, window.location.origin);
141
+ }
142
+ catch (_b) {
143
+ console.error("Invalid session_exchange_path; skipping token exchange.");
144
+ return null;
145
+ }
146
+ if (exchangeUrl.origin !== window.location.origin) {
147
+ console.error("Cross-origin session_exchange_path rejected; skipping token exchange.");
148
+ return null;
149
+ }
150
+ if (typeof fetch !== "function")
151
+ return null;
152
+ try {
153
+ const response = await fetch(exchangeUrl.toString(), {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({
157
+ code,
158
+ ...(verifier ? { code_verifier: verifier } : {}),
159
+ }),
160
+ });
161
+ if (!response.ok) {
162
+ if (!verifier) {
163
+ // Named failure mode (base44-dev/apper#17216): the login completed in
164
+ // a different tab/window than it started in, so the per-tab PKCE
165
+ // verifier is gone and the server fails closed. Logging in again from
166
+ // this tab works.
167
+ console.warn("Base44 SDK: SSO login could not be completed because it finished " +
168
+ "in a different browser tab than it started in (missing PKCE " +
169
+ "verifier). Please log in again.");
170
+ }
171
+ else {
172
+ console.error(`Base44 SDK: SSO session-code exchange failed (HTTP ${response.status}).`);
173
+ }
174
+ return null;
175
+ }
176
+ const data = await response.json();
177
+ const accessToken = data === null || data === void 0 ? void 0 : data.access_token;
178
+ return typeof accessToken === "string" && accessToken ? accessToken : null;
179
+ }
180
+ catch (e) {
181
+ console.error("Base44 SDK: SSO session-code exchange failed:", e);
182
+ return null;
183
+ }
184
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@base44-preview/sdk",
3
- "version": "0.8.42-pr.256.fb681e4",
3
+ "version": "0.8.43-pr.244.b43fd83",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",