@base44-preview/sdk 0.8.41-pr.244.e7d6747 → 0.8.42-pr.254.6a358ec
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 +3 -33
- package/dist/modules/analytics.d.ts +11 -0
- package/dist/modules/analytics.js +32 -2
- package/dist/modules/auth.js +34 -24
- package/dist/modules/connectors.types.d.ts +23 -19
- package/package.json +1 -1
- package/dist/utils/session-handoff.d.ts +0 -52
- package/dist/utils/session-handoff.js +0 -184
package/dist/client.js
CHANGED
|
@@ -5,7 +5,6 @@ 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";
|
|
9
8
|
import { createFunctionsModule } from "./modules/functions.js";
|
|
10
9
|
import { createAgentsModule } from "./modules/agents.js";
|
|
11
10
|
import { createAiGatewayModule } from "./modules/ai-gateway.js";
|
|
@@ -119,32 +118,10 @@ export function createClient(config) {
|
|
|
119
118
|
// requests during construction (notably analytics, which fires an init
|
|
120
119
|
// event whose flush calls auth.me()). Without this, the first User/me
|
|
121
120
|
// 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;
|
|
131
121
|
if (typeof window !== "undefined") {
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
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
|
-
}
|
|
122
|
+
const accessToken = token || getAccessToken();
|
|
123
|
+
if (accessToken) {
|
|
124
|
+
userAuthModule.setToken(accessToken);
|
|
148
125
|
}
|
|
149
126
|
}
|
|
150
127
|
const actorsModule = createActorsModule({
|
|
@@ -241,13 +218,6 @@ export function createClient(config) {
|
|
|
241
218
|
// We perform this check asynchronously to not block client creation
|
|
242
219
|
setTimeout(async () => {
|
|
243
220
|
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
|
-
}
|
|
251
221
|
const isAuthenticated = await userModules.auth.isAuthenticated();
|
|
252
222
|
if (!isAuthenticated) {
|
|
253
223
|
userModules.auth.redirectToLogin(window.location.href);
|
|
@@ -16,5 +16,16 @@ export declare const createAnalyticsModule: ({ axiosClient, serverUrl, appId, us
|
|
|
16
16
|
track: (params: TrackEventParams) => void;
|
|
17
17
|
cleanup: () => void;
|
|
18
18
|
};
|
|
19
|
+
/**
|
|
20
|
+
* Clears the memoized analytics session context.
|
|
21
|
+
*
|
|
22
|
+
* The context holds the `user_id` resolved by `auth.me()` and is reused for the
|
|
23
|
+
* lifetime of the session, so it has to be dropped whenever the identity
|
|
24
|
+
* changes. Without this, a visitor who loads a page anonymously and then logs in
|
|
25
|
+
* keeps reporting `user_id: null` on every subsequent event.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
export declare function resetAnalyticsSessionContext(): void;
|
|
19
30
|
export declare function getAnalyticsConfigFromUrlParams(): AnalyticsModuleOptions | undefined;
|
|
20
31
|
export declare function getAnalyticsSessionId(): string;
|
|
@@ -165,7 +165,12 @@ async function startAnalyticsProcessor(handleTrack, options) {
|
|
|
165
165
|
}
|
|
166
166
|
function startHeartBeatProcessor(track) {
|
|
167
167
|
var _a;
|
|
168
|
-
|
|
168
|
+
// Browser-only, like the other automatic events here (initialization, session
|
|
169
|
+
// duration, visibility). Outside a browser this timer fired a `me()` every
|
|
170
|
+
// interval for the lifetime of a long-lived server-side client, and kept the
|
|
171
|
+
// Node event loop alive. Explicit `analytics.track()` calls still work.
|
|
172
|
+
if (typeof window === "undefined" ||
|
|
173
|
+
analyticsSharedState.isHeartBeatProcessing ||
|
|
169
174
|
((_a = analyticsSharedState.config.heartBeatInterval) !== null && _a !== void 0 ? _a : 0) < 10) {
|
|
170
175
|
return () => { };
|
|
171
176
|
}
|
|
@@ -226,6 +231,20 @@ function transformEventDataToApiRequestData(sessionContext) {
|
|
|
226
231
|
});
|
|
227
232
|
}
|
|
228
233
|
let sessionContextPromise = null;
|
|
234
|
+
/**
|
|
235
|
+
* Clears the memoized analytics session context.
|
|
236
|
+
*
|
|
237
|
+
* The context holds the `user_id` resolved by `auth.me()` and is reused for the
|
|
238
|
+
* lifetime of the session, so it has to be dropped whenever the identity
|
|
239
|
+
* changes. Without this, a visitor who loads a page anonymously and then logs in
|
|
240
|
+
* keeps reporting `user_id: null` on every subsequent event.
|
|
241
|
+
*
|
|
242
|
+
* @internal
|
|
243
|
+
*/
|
|
244
|
+
export function resetAnalyticsSessionContext() {
|
|
245
|
+
analyticsSharedState.sessionContext = null;
|
|
246
|
+
sessionContextPromise = null;
|
|
247
|
+
}
|
|
229
248
|
async function getSessionContext(userAuthModule) {
|
|
230
249
|
if (!analyticsSharedState.sessionContext) {
|
|
231
250
|
if (!sessionContextPromise) {
|
|
@@ -241,7 +260,18 @@ async function getSessionContext(userAuthModule) {
|
|
|
241
260
|
session_id: sessionId,
|
|
242
261
|
}));
|
|
243
262
|
}
|
|
244
|
-
|
|
263
|
+
const pending = sessionContextPromise;
|
|
264
|
+
const context = await pending;
|
|
265
|
+
// Publish only if this lookup is still the current one. A reset that lands
|
|
266
|
+
// while the request is in flight nulls `sessionContextPromise`, and an
|
|
267
|
+
// unconditional write here would put the pre-reset identity back and pin it
|
|
268
|
+
// for the rest of the session. The awaited value is still returned: these
|
|
269
|
+
// events were queued before the identity changed, so that is who they
|
|
270
|
+
// belong to.
|
|
271
|
+
if (sessionContextPromise === pending) {
|
|
272
|
+
analyticsSharedState.sessionContext = context;
|
|
273
|
+
}
|
|
274
|
+
return context;
|
|
245
275
|
}
|
|
246
276
|
return analyticsSharedState.sessionContext;
|
|
247
277
|
}
|
package/dist/modules/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resetAnalyticsSessionContext } from "./analytics.js";
|
|
2
2
|
function isInsideIframe() {
|
|
3
3
|
if (typeof window === "undefined")
|
|
4
4
|
return false;
|
|
@@ -64,10 +64,33 @@ function loginViaPopup(url, redirectUrl, expectedOrigin) {
|
|
|
64
64
|
* @internal
|
|
65
65
|
*/
|
|
66
66
|
export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
67
|
+
// In-flight `me()` request, shared by concurrent callers. The analytics
|
|
68
|
+
// module resolves its session context through `me()` at client construction,
|
|
69
|
+
// at the same moment most apps issue their own `me()`. Browsers serialize the
|
|
70
|
+
// two identical GETs, so the second pays the first's full latency on every
|
|
71
|
+
// cold load.
|
|
72
|
+
//
|
|
73
|
+
// This shares the pending promise only — it is cleared as soon as the request
|
|
74
|
+
// settles, so no resolved user is ever retained. Caching the user across
|
|
75
|
+
// requests would leave the app rendering a stale identity after logout or a
|
|
76
|
+
// session swap.
|
|
77
|
+
let pendingMe = null;
|
|
78
|
+
const clearPendingMe = () => {
|
|
79
|
+
pendingMe = null;
|
|
80
|
+
};
|
|
67
81
|
return {
|
|
68
82
|
// Get current user information
|
|
69
83
|
async me() {
|
|
70
|
-
|
|
84
|
+
const request = pendingMe !== null && pendingMe !== void 0 ? pendingMe : axios.get(`/apps/${appId}/entities/User/me`).finally(() => {
|
|
85
|
+
// Only retire this request if it is still the shared one. An identity
|
|
86
|
+
// change mid-flight clears `pendingMe` and the next caller starts a
|
|
87
|
+
// fresh request; an unconditional clear here would retire that newer
|
|
88
|
+
// request instead, so a third caller would issue a duplicate.
|
|
89
|
+
if (pendingMe === request)
|
|
90
|
+
pendingMe = null;
|
|
91
|
+
});
|
|
92
|
+
pendingMe = request;
|
|
93
|
+
return request;
|
|
71
94
|
},
|
|
72
95
|
// Update current user data
|
|
73
96
|
async updateMe(data) {
|
|
@@ -105,32 +128,11 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
105
128
|
}
|
|
106
129
|
const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`;
|
|
107
130
|
// When running inside an iframe, use a popup to avoid OAuth providers
|
|
108
|
-
// blocking iframe navigation.
|
|
109
|
-
// they deliver the token via postMessage and never redeem a code (the
|
|
110
|
-
// backend skips the code mint when popup_origin is present).
|
|
131
|
+
// blocking iframe navigation.
|
|
111
132
|
if (isInsideIframe()) {
|
|
112
133
|
const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
|
|
113
134
|
return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
|
|
114
135
|
}
|
|
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
|
-
}
|
|
134
136
|
// Default: full-page redirect
|
|
135
137
|
window.location.href = loginUrl;
|
|
136
138
|
},
|
|
@@ -138,6 +140,10 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
138
140
|
logout(redirectUrl) {
|
|
139
141
|
// Remove token from axios headers (always do this)
|
|
140
142
|
delete axios.defaults.headers.common["Authorization"];
|
|
143
|
+
// Drop identity resolved under the previous session: a `me()` already in
|
|
144
|
+
// flight would otherwise resolve into callers that run after the logout.
|
|
145
|
+
clearPendingMe();
|
|
146
|
+
resetAnalyticsSessionContext();
|
|
141
147
|
// Only do the rest if in a browser environment
|
|
142
148
|
if (typeof window !== "undefined") {
|
|
143
149
|
// Remove token from localStorage
|
|
@@ -162,6 +168,10 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
162
168
|
setToken(token, saveToStorage = true) {
|
|
163
169
|
if (!token)
|
|
164
170
|
return;
|
|
171
|
+
// Same reasoning as in `logout`: the identity changes here, so anything
|
|
172
|
+
// resolved for the previous one must not be handed to later callers.
|
|
173
|
+
clearPendingMe();
|
|
174
|
+
resetAnalyticsSessionContext();
|
|
165
175
|
// handle token change for axios clients
|
|
166
176
|
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
|
167
177
|
functionsAxiosClient.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
|
@@ -53,11 +53,14 @@ export interface AppUserConnectorConnectionResponse {
|
|
|
53
53
|
*
|
|
54
54
|
* ## Shared connectors
|
|
55
55
|
*
|
|
56
|
-
* All app users share a single OAuth token. Use this for shared accounts. For example, posting to a company Slack channel or reading from a shared Google Calendar.
|
|
56
|
+
* All app users share a single OAuth token. Use this for shared accounts. For example, posting to a company Slack channel or reading from a shared Google Calendar.
|
|
57
57
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* Shared connectors come in two forms, depending on how the connector is set up. Both return the same app-wide token, so they differ only in how you identify the connector in code:
|
|
59
|
+
*
|
|
60
|
+
* - **Platform connectors** are connected from the app's Integration settings or with the [`connectors push`](/developers/references/cli/commands/connectors-push) CLI command, and are identified by an [integration type](#available-connectors) string. Retrieve them with {@linkcode getConnection | getConnection()}.
|
|
61
|
+
* - **Workspace-registered connectors** are backed by your own OAuth app, registered once in Workspace Settings and consented to by the app builder. They are identified by a connector ID instead of an integration type. Retrieve them with {@linkcode getWorkspaceConnection | getWorkspaceConnection()}. Connectors whose OAuth app is specific to your own account, such as Databricks and Snowflake, work this way.
|
|
62
|
+
*
|
|
63
|
+
* To use a shared connector, call the matching method on the service role client `base44.asServiceRole.connectors` from a backend function, then 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, that you need to build the API URL.
|
|
61
64
|
*
|
|
62
65
|
* ## App user connectors
|
|
63
66
|
*
|
|
@@ -70,7 +73,7 @@ export interface AppUserConnectorConnectionResponse {
|
|
|
70
73
|
*
|
|
71
74
|
* ## Available connectors
|
|
72
75
|
*
|
|
73
|
-
*
|
|
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.
|
|
74
77
|
*
|
|
75
78
|
* | Service | Type identifier |
|
|
76
79
|
* |---|---|
|
|
@@ -204,6 +207,8 @@ export interface ConnectorsModule {
|
|
|
204
207
|
*
|
|
205
208
|
* Use this when a single shared account is connected and all app users access the same token. For per-user tokens, use [`getCurrentAppUserConnection()`](#getcurrentappuserconnection) instead.
|
|
206
209
|
*
|
|
210
|
+
* This form is for platform connectors identified by an integration type. Connectors backed by your own OAuth app registered in Workspace Settings, such as Databricks and Snowflake, are retrieved by connector ID with {@linkcode getWorkspaceConnection | getWorkspaceConnection()} instead.
|
|
211
|
+
*
|
|
207
212
|
* Some connectors require connection-specific parameters to build API calls.
|
|
208
213
|
* In such cases, the returned `connectionConfig` is an object with the additional parameters. If there are no extra parameters needed for the connection, the `connectionConfig` is `null`.
|
|
209
214
|
*
|
|
@@ -257,28 +262,27 @@ export interface ConnectorsModule {
|
|
|
257
262
|
*/
|
|
258
263
|
getConnection(integrationType: ConnectorIntegrationType): Promise<ConnectorConnectionResponse>;
|
|
259
264
|
/**
|
|
260
|
-
* Retrieves the OAuth access token and connection configuration for a
|
|
261
|
-
* (a connector backed by an OAuth app registered in the workspace, consented to once by the app builder).
|
|
265
|
+
* Retrieves the shared OAuth access token and connection configuration for a [workspace-registered connector](#shared-connectors).
|
|
262
266
|
*
|
|
263
|
-
* Use this
|
|
264
|
-
* workspace-connector ID rather than a platform integration type. The token returned represents
|
|
265
|
-
* the app builder's consent against the workspace's OAuth app and is shared across all app users
|
|
266
|
-
* of the app — identical semantics to the platform-shared {@link getConnection} form,
|
|
267
|
-
* differing only in which OAuth app was used to produce the token.
|
|
267
|
+
* Use this for a connector backed by your own OAuth app that you register in Workspace Settings, such as Databricks or Snowflake. The app builder consents to the connector once, and the returned token is shared across all app users of the app. This is the shared-token counterpart to {@linkcode getCurrentAppUserConnection | getCurrentAppUserConnection()}, which returns a per-user token for the same kind of connector. The semantics match {@linkcode getConnection | getConnection()}, except that you identify the connector by ID rather than by integration type.
|
|
268
268
|
*
|
|
269
|
-
*
|
|
269
|
+
* Some connectors require connection-specific parameters to build API calls. In such cases, the returned `connectionConfig` is an object with those parameters, such as the account subdomain used to construct the API URL. When no extra parameters are needed, `connectionConfig` is `null`.
|
|
270
|
+
*
|
|
271
|
+
* @param connectorId - The ID of the workspace connector, not the integration type string. You can find it on the connector's settings page in Workspace Settings.
|
|
270
272
|
* @returns Promise resolving to a {@link ConnectorConnectionResponse} with `accessToken` and `connectionConfig`.
|
|
271
273
|
*
|
|
272
274
|
* @example
|
|
273
275
|
* ```typescript
|
|
274
|
-
* //
|
|
275
|
-
*
|
|
276
|
-
*
|
|
276
|
+
* // Snowflake connection
|
|
277
|
+
* // Retrieve the shared token and run a statement against the account
|
|
278
|
+
* const { accessToken, connectionConfig } = await base44.asServiceRole.connectors.getWorkspaceConnection('abc123def');
|
|
279
|
+
*
|
|
280
|
+
* const response = await fetch(
|
|
281
|
+
* `https://${connectionConfig?.subdomain}.snowflakecomputing.com/api/v2/statements`,
|
|
282
|
+
* { headers: { Authorization: `Bearer ${accessToken}` } }
|
|
277
283
|
* );
|
|
278
284
|
*
|
|
279
|
-
* const
|
|
280
|
-
* headers: { Authorization: `Bearer ${accessToken}` },
|
|
281
|
-
* });
|
|
285
|
+
* const data = await response.json();
|
|
282
286
|
* ```
|
|
283
287
|
*/
|
|
284
288
|
getWorkspaceConnection(connectorId: string): Promise<ConnectorConnectionResponse>;
|
package/package.json
CHANGED
|
@@ -1,52 +0,0 @@
|
|
|
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;
|
|
@@ -1,184 +0,0 @@
|
|
|
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
|
-
}
|