@base44-preview/sdk 0.8.41-pr.243.98f858c → 0.8.41-pr.244.e7d6747

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";
@@ -118,10 +119,32 @@ export function createClient(config) {
118
119
  // requests during construction (notably analytics, which fires an init
119
120
  // event whose flush calls auth.me()). Without this, the first User/me
120
121
  // request is built before setToken runs and goes out unauthenticated.
122
+ //
123
+ // Precedence: an explicit config token wins (legacy behavior); then a PKCE
124
+ // session-code handoff in the URL (base44-dev/apper#17216 §5.2) — the user
125
+ // just completed a login, so it outranks any stored token, mirroring how
126
+ // getAccessToken prefers a URL access_token over localStorage; then the
127
+ // legacy capture. When the server spoke legacy — including after a backend
128
+ // rollback — redeemSessionHandoffCode() returns null synchronously and the
129
+ // legacy capture below runs unchanged.
130
+ let tokenBootstrap = null;
121
131
  if (typeof window !== "undefined") {
122
- const accessToken = token || getAccessToken();
123
- if (accessToken) {
124
- userAuthModule.setToken(accessToken);
132
+ const sessionExchange = token ? null : redeemSessionHandoffCode();
133
+ if (sessionExchange) {
134
+ tokenBootstrap = sessionExchange.then((exchangedToken) => {
135
+ // On exchange failure, fall back to any stored token rather than
136
+ // leaving auth state empty (same fallback getAccessToken applies).
137
+ const accessToken = exchangedToken || getAccessToken();
138
+ if (accessToken) {
139
+ userAuthModule.setToken(accessToken);
140
+ }
141
+ });
142
+ }
143
+ else {
144
+ const accessToken = token || getAccessToken();
145
+ if (accessToken) {
146
+ userAuthModule.setToken(accessToken);
147
+ }
125
148
  }
126
149
  }
127
150
  const actorsModule = createActorsModule({
@@ -218,6 +241,13 @@ export function createClient(config) {
218
241
  // We perform this check asynchronously to not block client creation
219
242
  setTimeout(async () => {
220
243
  try {
244
+ // A pending session-code exchange must settle before the auth probe.
245
+ // Probing early would see no token, redirect to login, and abandon
246
+ // the in-flight exchange — minting a fresh code on every round, i.e.
247
+ // a login loop (the exact BUG-787 failure shape).
248
+ if (tokenBootstrap) {
249
+ await tokenBootstrap;
250
+ }
221
251
  const isAuthenticated = await userModules.auth.isAuthenticated();
222
252
  if (!isAuthenticated) {
223
253
  userModules.auth.redirectToLogin(window.location.href);
@@ -1,3 +1,4 @@
1
+ import { prepareSessionHandoffKickoff } from "../utils/session-handoff.js";
1
2
  function isInsideIframe() {
2
3
  if (typeof window === "undefined")
3
4
  return false;
@@ -104,11 +105,32 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
104
105
  }
105
106
  const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`;
106
107
  // When running inside an iframe, use a popup to avoid OAuth providers
107
- // blocking iframe navigation.
108
+ // blocking iframe navigation. Popups stay on the exact legacy kickoff:
109
+ // they deliver the token via postMessage and never redeem a code (the
110
+ // backend skips the code mint when popup_origin is present).
108
111
  if (isInsideIframe()) {
109
112
  const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
110
113
  return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
111
114
  }
115
+ // Full-page SSO redirect: offer the PKCE session-code handoff
116
+ // (base44-dev/apper#17216 §5.2). The backend decides per request whether
117
+ // to use it; a server that answers with the legacy ?access_token= —
118
+ // including after a backend rollback — is honored unchanged at
119
+ // redemption. If PKCE can't be prepared locally, kick off with the
120
+ // unmodified legacy URL (version=2 without a valid challenge is a 400
121
+ // at /login, so it's all-or-nothing).
122
+ if (provider === "sso") {
123
+ prepareSessionHandoffKickoff()
124
+ .then((pkceQuery) => {
125
+ window.location.href = pkceQuery
126
+ ? `${loginUrl}${pkceQuery}`
127
+ : loginUrl;
128
+ })
129
+ .catch(() => {
130
+ window.location.href = loginUrl;
131
+ });
132
+ return;
133
+ }
112
134
  // Default: full-page redirect
113
135
  window.location.href = loginUrl;
114
136
  },
@@ -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.41-pr.243.98f858c",
3
+ "version": "0.8.41-pr.244.e7d6747",
4
4
  "description": "JavaScript SDK for Base44 API",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",