@base44-preview/sdk 0.8.43-pr.244.b43fd83 → 0.8.43-pr.256.e07d4b3
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/index.d.ts +1 -1
- package/dist/modules/auth.js +1 -23
- package/dist/modules/connectors.js +58 -0
- package/dist/modules/connectors.types.d.ts +122 -0
- package/dist/utils/axios-client.js +2 -2
- 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";
|
|
@@ -120,32 +119,10 @@ export function createClient(config) {
|
|
|
120
119
|
// requests during construction (notably analytics, which fires an init
|
|
121
120
|
// event whose flush calls auth.me()). Without this, the first User/me
|
|
122
121
|
// 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;
|
|
132
122
|
if (typeof window !== "undefined") {
|
|
133
|
-
const
|
|
134
|
-
if (
|
|
135
|
-
|
|
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
|
-
}
|
|
123
|
+
const accessToken = token || getAccessToken();
|
|
124
|
+
if (accessToken) {
|
|
125
|
+
userAuthModule.setToken(accessToken);
|
|
149
126
|
}
|
|
150
127
|
}
|
|
151
128
|
const actorsModule = createActorsModule({
|
|
@@ -242,13 +219,6 @@ export function createClient(config) {
|
|
|
242
219
|
// We perform this check asynchronously to not block client creation
|
|
243
220
|
setTimeout(async () => {
|
|
244
221
|
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
|
-
}
|
|
252
222
|
const isAuthenticated = await userModules.auth.isAuthenticated();
|
|
253
223
|
if (!isAuthenticated) {
|
|
254
224
|
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, } from "./modules/connectors.types.js";
|
|
17
|
+
export type { ConnectorsModule, UserConnectorsModule, ConnectorApiRequest, ConnectorApiResponse, ConnectorApiResponsePhase, } 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";
|
package/dist/modules/auth.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { resetAnalyticsSessionContext } from "./analytics.js";
|
|
2
|
-
import { prepareSessionHandoffKickoff } from "../utils/session-handoff.js";
|
|
3
2
|
function isInsideIframe() {
|
|
4
3
|
if (typeof window === "undefined")
|
|
5
4
|
return false;
|
|
@@ -136,32 +135,11 @@ export function createAuthModule(axios, functionsAxiosClient, appId, options) {
|
|
|
136
135
|
}
|
|
137
136
|
const loginUrl = `${options.appBaseUrl}/api${authPath}?${queryParams}`;
|
|
138
137
|
// When running inside an iframe, use a popup to avoid OAuth providers
|
|
139
|
-
// blocking iframe navigation.
|
|
140
|
-
// they deliver the token via postMessage and never redeem a code (the
|
|
141
|
-
// backend skips the code mint when popup_origin is present).
|
|
138
|
+
// blocking iframe navigation.
|
|
142
139
|
if (isInsideIframe()) {
|
|
143
140
|
const popupLoginUrl = `${loginUrl}&popup_origin=${encodeURIComponent(window.location.origin)}`;
|
|
144
141
|
return loginViaPopup(popupLoginUrl, redirectUrl, window.location.origin);
|
|
145
142
|
}
|
|
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
|
-
}
|
|
165
143
|
// Default: full-page redirect
|
|
166
144
|
window.location.href = loginUrl;
|
|
167
145
|
},
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
const CONNECTOR_API_METHODS = new Set([
|
|
2
|
+
"GET",
|
|
3
|
+
"POST",
|
|
4
|
+
"PUT",
|
|
5
|
+
"PATCH",
|
|
6
|
+
"DELETE",
|
|
7
|
+
"HEAD",
|
|
8
|
+
]);
|
|
1
9
|
/**
|
|
2
10
|
* Creates the Connectors module for the Base44 SDK.
|
|
3
11
|
*
|
|
@@ -68,6 +76,56 @@ export function createConnectorsModule(axios, appId) {
|
|
|
68
76
|
connectionConfig: (_a = data.connection_config) !== null && _a !== void 0 ? _a : null,
|
|
69
77
|
};
|
|
70
78
|
},
|
|
79
|
+
async callApi(integrationType, request) {
|
|
80
|
+
assertNonEmptyString(integrationType, "Integration type");
|
|
81
|
+
return proxyCall(axios, `/apps/${appId}/connectors/${integrationType}/call`, request);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function assertNonEmptyString(value, label) {
|
|
86
|
+
if (!value || typeof value !== "string") {
|
|
87
|
+
throw new Error(`${label} is required and must be a string`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* POST a request to the connector proxy and normalize the response.
|
|
92
|
+
*
|
|
93
|
+
* The proxy reports upstream outcomes in the body rather than as HTTP status, so
|
|
94
|
+
* a provider 4xx/5xx arrives here as a resolved response with `success: false` —
|
|
95
|
+
* only Base44-side failures reject through the axios error interceptor.
|
|
96
|
+
*
|
|
97
|
+
* @internal
|
|
98
|
+
*/
|
|
99
|
+
async function proxyCall(axios, url, request) {
|
|
100
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
101
|
+
if (!request || typeof request !== "object") {
|
|
102
|
+
throw new Error("Request is required and must be an object");
|
|
103
|
+
}
|
|
104
|
+
assertNonEmptyString(request.path, "Request path");
|
|
105
|
+
const method = (_a = request.method) !== null && _a !== void 0 ? _a : "GET";
|
|
106
|
+
if (!CONNECTOR_API_METHODS.has(method)) {
|
|
107
|
+
throw new Error("Request method must be one of GET, POST, PUT, PATCH, DELETE, or HEAD");
|
|
108
|
+
}
|
|
109
|
+
const response = await axios.post(url, {
|
|
110
|
+
method,
|
|
111
|
+
// Omitted rather than sent as null so the proxy applies the connector's
|
|
112
|
+
// declared default host.
|
|
113
|
+
...(request.host === undefined ? {} : { host: request.host }),
|
|
114
|
+
path: request.path,
|
|
115
|
+
query: (_b = request.query) !== null && _b !== void 0 ? _b : {},
|
|
116
|
+
headers: (_c = request.headers) !== null && _c !== void 0 ? _c : {},
|
|
117
|
+
body: (_d = request.body) !== null && _d !== void 0 ? _d : null,
|
|
118
|
+
});
|
|
119
|
+
const data = response;
|
|
120
|
+
return {
|
|
121
|
+
success: data.success,
|
|
122
|
+
phase: data.phase,
|
|
123
|
+
status: (_e = data.status_code) !== null && _e !== void 0 ? _e : null,
|
|
124
|
+
data: data.data,
|
|
125
|
+
dataBase64: (_f = data.data_base64) !== null && _f !== void 0 ? _f : null,
|
|
126
|
+
contentType: (_g = data.content_type) !== null && _g !== void 0 ? _g : null,
|
|
127
|
+
headers: (_h = data.headers) !== null && _h !== void 0 ? _h : {},
|
|
128
|
+
creditsCharged: (_j = data.credits_charged) !== null && _j !== void 0 ? _j : 0,
|
|
71
129
|
};
|
|
72
130
|
}
|
|
73
131
|
/**
|
|
@@ -41,6 +41,81 @@ 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
|
+
* How far a metered connector call progressed through the Base44 proxy.
|
|
46
|
+
*
|
|
47
|
+
* Only `not_sent` proves that the provider did not execute the request.
|
|
48
|
+
* `timed_out` and `sent_unconfirmed` may have executed upstream, so do not
|
|
49
|
+
* automatically retry non-idempotent requests based on those phases.
|
|
50
|
+
*/
|
|
51
|
+
export type ConnectorApiResponsePhase = "not_sent" | "responded" | "timed_out" | "sent_unconfirmed";
|
|
52
|
+
/**
|
|
53
|
+
* A request to forward to a metered connector's API through the Base44 proxy.
|
|
54
|
+
*/
|
|
55
|
+
export interface ConnectorApiRequest {
|
|
56
|
+
/** HTTP method for the upstream request. Defaults to `'GET'`. */
|
|
57
|
+
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
|
|
58
|
+
/**
|
|
59
|
+
* Which of the connector's API hosts to call, by the name it declares.
|
|
60
|
+
* Omit for its default host (the first one declared). Only relevant for
|
|
61
|
+
* connectors that expose more than one host.
|
|
62
|
+
*/
|
|
63
|
+
host?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Path relative to the connector's API root, starting with `/`, such as `'/2/tweets'`.
|
|
66
|
+
*
|
|
67
|
+
* Must not be an absolute URL. Query parameters may be included here or passed
|
|
68
|
+
* separately as {@link query}; either way they are forwarded and priced identically.
|
|
69
|
+
*/
|
|
70
|
+
path: string;
|
|
71
|
+
/** Query parameters. Merged into the request URL alongside any already present in {@link path}. */
|
|
72
|
+
query?: Record<string, string | number | boolean | Array<string | number>>;
|
|
73
|
+
/** Extra request headers. Only headers the connector explicitly allows are forwarded; the rest are dropped. */
|
|
74
|
+
headers?: Record<string, string>;
|
|
75
|
+
/** JSON request body. Ignored for `GET` and `HEAD`. */
|
|
76
|
+
body?: unknown;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The upstream API's response, as returned by the Base44 connector proxy.
|
|
80
|
+
*/
|
|
81
|
+
export interface ConnectorApiResponse<T = unknown> {
|
|
82
|
+
/** `true` only when the upstream API returned a 2xx status. Proxy and upstream errors are `false`. */
|
|
83
|
+
success: boolean;
|
|
84
|
+
/** How far the call progressed. Only `not_sent` proves the provider did not execute it. */
|
|
85
|
+
phase: ConnectorApiResponsePhase;
|
|
86
|
+
/** The upstream HTTP status code, or `null` when no response was received. */
|
|
87
|
+
status: number | null;
|
|
88
|
+
/**
|
|
89
|
+
* The parsed upstream response body, or proxy error details when no response
|
|
90
|
+
* was received. `null` when the response was binary — see {@link dataBase64}.
|
|
91
|
+
*/
|
|
92
|
+
data: T;
|
|
93
|
+
/**
|
|
94
|
+
* The response body base64-encoded, for the media types the connector declares
|
|
95
|
+
* as binary (images, PDFs). Set instead of {@link data}, never alongside it.
|
|
96
|
+
*/
|
|
97
|
+
dataBase64: string | null;
|
|
98
|
+
/** The response media type, set only alongside {@link dataBase64}. */
|
|
99
|
+
contentType: string | null;
|
|
100
|
+
/** The subset of upstream response headers the connector exposes, typically rate-limit counters. */
|
|
101
|
+
headers: Record<string, string>;
|
|
102
|
+
/** Integration credits billed to the workspace for this call. */
|
|
103
|
+
creditsCharged: number;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Raw proxy response shape. Mapped to {@link ConnectorApiResponse} before being returned.
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
export interface ConnectorProxyRawResponse {
|
|
110
|
+
success: boolean;
|
|
111
|
+
phase: ConnectorApiResponsePhase;
|
|
112
|
+
status_code: number | null;
|
|
113
|
+
data: unknown;
|
|
114
|
+
data_base64: string | null;
|
|
115
|
+
content_type: string | null;
|
|
116
|
+
headers: Record<string, string>;
|
|
117
|
+
credits_charged: number;
|
|
118
|
+
}
|
|
44
119
|
/**
|
|
45
120
|
* Connectors module for managing OAuth tokens for external services.
|
|
46
121
|
*
|
|
@@ -71,6 +146,18 @@ export interface AppUserConnectorConnectionResponse {
|
|
|
71
146
|
* 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.
|
|
72
147
|
* 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.
|
|
73
148
|
*
|
|
149
|
+
* ## Metered connectors
|
|
150
|
+
*
|
|
151
|
+
* 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.
|
|
152
|
+
*
|
|
153
|
+
* 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()}.
|
|
154
|
+
*
|
|
155
|
+
* Two things to keep in mind when writing against a metered connector:
|
|
156
|
+
*
|
|
157
|
+
* - **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`.
|
|
158
|
+
* - **Provider and transport outcomes are returned, not thrown.** A provider `4xx`/`5xx` or a connection failure comes back as `success: false` with its `phase`; authorization, quota, and invalid proxy requests reject the promise.
|
|
159
|
+
* - **Only `phase: 'not_sent'` proves the provider did not execute the request.** A timeout or in-flight failure may have executed upstream, so do not automatically retry a non-idempotent call unless the provider supports an idempotency key.
|
|
160
|
+
*
|
|
74
161
|
* ## Available connectors
|
|
75
162
|
*
|
|
76
163
|
* 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.
|
|
@@ -328,6 +415,41 @@ export interface ConnectorsModule {
|
|
|
328
415
|
* ```
|
|
329
416
|
*/
|
|
330
417
|
getCurrentAppUserConnection(connectorId: string): Promise<AppUserConnectorConnectionResponse>;
|
|
418
|
+
/**
|
|
419
|
+
* Calls a [metered connector's](#metered-connectors) API through the Base44 proxy.
|
|
420
|
+
*
|
|
421
|
+
* 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.
|
|
422
|
+
*
|
|
423
|
+
* @param integrationType - The type of integration, such as `'x'`. See [Available connectors](#available-connectors).
|
|
424
|
+
* @param request - The upstream request to forward. See {@link ConnectorApiRequest}.
|
|
425
|
+
* @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.
|
|
426
|
+
*
|
|
427
|
+
* @example
|
|
428
|
+
* ```typescript
|
|
429
|
+
* // Post to X
|
|
430
|
+
* const res = await base44.asServiceRole.connectors.callApi('x', {
|
|
431
|
+
* method: 'POST',
|
|
432
|
+
* path: '/2/tweets',
|
|
433
|
+
* body: { text: 'Shipped!' },
|
|
434
|
+
* });
|
|
435
|
+
*
|
|
436
|
+
* if (!res.success) {
|
|
437
|
+
* console.error('X rejected the post', res.status, res.data);
|
|
438
|
+
* }
|
|
439
|
+
* ```
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* ```typescript
|
|
443
|
+
* // Read, with query parameters and a look at what the call cost
|
|
444
|
+
* const res = await base44.asServiceRole.connectors.callApi('x', {
|
|
445
|
+
* path: '/2/tweets/search/recent',
|
|
446
|
+
* query: { query: 'base44', max_results: 10 },
|
|
447
|
+
* });
|
|
448
|
+
*
|
|
449
|
+
* console.log(`${res.creditsCharged} credits`, res.data);
|
|
450
|
+
* ```
|
|
451
|
+
*/
|
|
452
|
+
callApi<T = unknown>(integrationType: ConnectorIntegrationType, request: ConnectorApiRequest): Promise<ConnectorApiResponse<T>>;
|
|
331
453
|
}
|
|
332
454
|
/**
|
|
333
455
|
* User-scoped connectors module for managing app user OAuth connections.
|
|
@@ -185,11 +185,11 @@ export function createAxiosClient({ baseURL, headers = {}, token, interceptRespo
|
|
|
185
185
|
}
|
|
186
186
|
return response.data;
|
|
187
187
|
}, (error) => {
|
|
188
|
-
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
188
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
|
|
189
189
|
const message = ((_b = (_a = error.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.message) ||
|
|
190
190
|
((_d = (_c = error.response) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d.detail) ||
|
|
191
191
|
error.message;
|
|
192
|
-
const base44Error = new Base44Error(message, (_e = error.response) === null || _e === void 0 ? void 0 : _e.status, (_g = (_f = error.response) === null || _f === void 0 ? void 0 : _f.data) === null || _g === void 0 ? void 0 : _g.code, (
|
|
192
|
+
const base44Error = new Base44Error(message, (_e = error.response) === null || _e === void 0 ? void 0 : _e.status, (_m = (_h = (_g = (_f = error.response) === null || _f === void 0 ? void 0 : _f.data) === null || _g === void 0 ? void 0 : _g.code) !== null && _h !== void 0 ? _h : (_l = (_k = (_j = error.response) === null || _j === void 0 ? void 0 : _j.headers) === null || _k === void 0 ? void 0 : _k.get) === null || _l === void 0 ? void 0 : _l.call(_k, "x-base44-connector-error")) !== null && _m !== void 0 ? _m : (_p = (_o = error.response) === null || _o === void 0 ? void 0 : _o.headers) === null || _p === void 0 ? void 0 : _p["x-base44-connector-error"], (_q = error.response) === null || _q === void 0 ? void 0 : _q.data, error);
|
|
193
193
|
// Log errors in development
|
|
194
194
|
if (process.env.NODE_ENV !== "production") {
|
|
195
195
|
safeErrorLog("[Base44 SDK Error]", base44Error);
|
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
|
-
}
|