@krishp/one-auth 0.0.1

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.
@@ -0,0 +1,339 @@
1
+ import { ConnectionRecord, EventLinkProps, EventProps } from "./types";
2
+ import { createWindow, EventLinkWindow, VISIBLE_IFRAME_ID } from "./window";
3
+
4
+ // Track processed messages to prevent duplicates (defense-in-depth)
5
+ const processedMessages = new Set<string>();
6
+ const MESSAGE_EXPIRY_MS = 5000;
7
+
8
+ // One-shot guard so we only handle a given OAuth return once per page
9
+ // load. The hook can be called multiple times across React re-renders;
10
+ // without this guard we'd open multiple check iframes for the same
11
+ // return.
12
+ let oauthReturnHandled = false;
13
+
14
+ // Query param names used by the same-window OAuth redirect flow.
15
+ // These appear on the parent app's URL after the user comes back from
16
+ // the OAuth provider. The package detects them on init, processes the
17
+ // result, and strips them from the URL.
18
+ const RETURN_STATE_PARAM = "one_auth_state";
19
+ const RETURN_ERROR_PARAM = "one_auth_error";
20
+
21
+ // Separator used between the original OAuth state and the base64url
22
+ // encoded return URL. Tilde is in the URL "unreserved" set so it
23
+ // survives URL encoding intact, and it's not used by base64url so
24
+ // splitting is unambiguous.
25
+ const STATE_SEPARATOR = "~";
26
+
27
+ // ---- base64url helpers (no deps) -------------------------------------
28
+
29
+ function base64urlEncode(str: string): string {
30
+ return btoa(str)
31
+ .replace(/\+/g, "-")
32
+ .replace(/\//g, "_")
33
+ .replace(/=+$/, "");
34
+ }
35
+
36
+ // ---- OAuth URL state injection ---------------------------------------
37
+
38
+ // Replaces the `state` query param in the OAuth provider URL with one
39
+ // that has the return URL appended. Falls back to a string replace if
40
+ // the URL constructor can't parse the input (very unusual but
41
+ // defensive).
42
+ function injectReturnUrlIntoState(
43
+ oauthUrl: string,
44
+ originalState: string,
45
+ returnUrl: string
46
+ ): string {
47
+ const newState = `${originalState}${STATE_SEPARATOR}${base64urlEncode(returnUrl)}`;
48
+ try {
49
+ const parsed = new URL(oauthUrl);
50
+ parsed.searchParams.set("state", newState);
51
+ return parsed.toString();
52
+ } catch {
53
+ return oauthUrl
54
+ .replace(
55
+ `state=${encodeURIComponent(originalState)}`,
56
+ `state=${encodeURIComponent(newState)}`
57
+ )
58
+ .replace(
59
+ // Some OAuth URLs include the state without URL-encoding it
60
+ `state=${originalState}`,
61
+ `state=${encodeURIComponent(newState)}`
62
+ );
63
+ }
64
+ }
65
+
66
+ // ---- URL cleanup -----------------------------------------------------
67
+
68
+ function stripReturnParamsFromUrl() {
69
+ if (typeof window === "undefined") return;
70
+ try {
71
+ const url = new URL(window.location.href);
72
+ let changed = false;
73
+ if (url.searchParams.has(RETURN_STATE_PARAM)) {
74
+ url.searchParams.delete(RETURN_STATE_PARAM);
75
+ changed = true;
76
+ }
77
+ if (url.searchParams.has(RETURN_ERROR_PARAM)) {
78
+ url.searchParams.delete(RETURN_ERROR_PARAM);
79
+ changed = true;
80
+ }
81
+ if (changed) {
82
+ window.history.replaceState({}, document.title, url.toString());
83
+ }
84
+ } catch {
85
+ // No-op: history.replaceState is best-effort cleanup.
86
+ }
87
+ }
88
+
89
+ // ---- OAuth return handler --------------------------------------------
90
+
91
+ // Opens the auth iframe in "checkState" mode after the user comes back
92
+ // from the OAuth provider. The iframe at auth.withone.ai polls
93
+ // /v1/connections/oauth/check?state=X and renders a loading spinner
94
+ // followed by a success or failure screen. When the backend reports a
95
+ // final status the iframe posts LINK_SUCCESS / LINK_ERROR back here
96
+ // immediately so the consumer's callback fires in parallel with the
97
+ // visible UI. The iframe stays visible until the user dismisses it
98
+ // with the X button (which fires EXIT_EVENT_LINK). NO auto-close.
99
+ function handleOAuthReturn(props: EventLinkProps, state: string) {
100
+ const checkWindow = new EventLinkWindow({
101
+ ...props,
102
+ checkState: state,
103
+ });
104
+
105
+ let cleaned = false;
106
+ let resultDelivered = false;
107
+
108
+ const handler = (event: MessageEvent) => {
109
+ if (typeof window === "undefined") return;
110
+
111
+ const iframe = document.getElementById(
112
+ VISIBLE_IFRAME_ID
113
+ ) as HTMLIFrameElement | null;
114
+
115
+ // Only accept messages from this iframe instance.
116
+ if (!iframe || event.source !== iframe.contentWindow) {
117
+ return;
118
+ }
119
+
120
+ const eventData = (event as unknown as EventProps).data;
121
+ if (!eventData?.messageType) return;
122
+
123
+ if (eventData.messageType === "LINK_SUCCESS") {
124
+ if (!resultDelivered) {
125
+ resultDelivered = true;
126
+ try {
127
+ props.onSuccess?.(eventData.message as ConnectionRecord);
128
+ } catch {
129
+ /* consumer callback errors are not our problem */
130
+ }
131
+ stripReturnParamsFromUrl();
132
+ }
133
+ } else if (eventData.messageType === "LINK_ERROR") {
134
+ if (!resultDelivered) {
135
+ resultDelivered = true;
136
+ try {
137
+ props.onError?.(eventData.message as string);
138
+ } catch {
139
+ /* consumer callback errors are not our problem */
140
+ }
141
+ stripReturnParamsFromUrl();
142
+ }
143
+ } else if (eventData.messageType === "EXIT_EVENT_LINK") {
144
+ // User clicked X. Tear down everything. If onSuccess/onError
145
+ // hasn't fired yet (user dismissed during polling), fire
146
+ // onClose so the consumer knows the user bailed.
147
+ if (!resultDelivered) {
148
+ try {
149
+ props.onClose?.();
150
+ } catch {
151
+ /* ignore */
152
+ }
153
+ }
154
+ cleanup();
155
+ }
156
+ };
157
+
158
+ function cleanup() {
159
+ if (cleaned) return;
160
+ cleaned = true;
161
+ if (typeof window !== "undefined") {
162
+ window.removeEventListener("message", handler);
163
+ }
164
+ checkWindow.closeLink();
165
+ stripReturnParamsFromUrl();
166
+ // Reset the module-level guard so the NEXT OAuth flow on this page
167
+ // can be detected. Without this, back-to-back OAuth flows in an
168
+ // SPA (no full page reload between them) would silently skip the
169
+ // second return detection.
170
+ oauthReturnHandled = false;
171
+ }
172
+
173
+ if (typeof window !== "undefined") {
174
+ window.addEventListener("message", handler);
175
+ }
176
+ checkWindow.openLink();
177
+ }
178
+
179
+ // Fired when the callback page redirects back with `?one_auth_error=`.
180
+ // The OAuth provider returned an error (e.g., the user denied consent)
181
+ // and the callback page knows there's nothing to poll. We just notify
182
+ // the consumer and strip the URL params.
183
+ function handleOAuthReturnError(props: EventLinkProps, errorMessage: string) {
184
+ setTimeout(() => {
185
+ try {
186
+ props.onError?.(errorMessage);
187
+ } finally {
188
+ stripReturnParamsFromUrl();
189
+ }
190
+ }, 0);
191
+ }
192
+
193
+ function detectOAuthReturn(props: EventLinkProps) {
194
+ if (typeof window === "undefined") return;
195
+ if (oauthReturnHandled) return;
196
+
197
+ let params: URLSearchParams;
198
+ try {
199
+ params = new URLSearchParams(window.location.search);
200
+ } catch {
201
+ return;
202
+ }
203
+
204
+ const errorParam = params.get(RETURN_ERROR_PARAM);
205
+ const stateParam = params.get(RETURN_STATE_PARAM);
206
+
207
+ // No return params — nothing to do.
208
+ if (!errorParam && !stateParam) {
209
+ return;
210
+ }
211
+
212
+ oauthReturnHandled = true;
213
+
214
+ if (errorParam) {
215
+ handleOAuthReturnError(props, errorParam);
216
+ return;
217
+ }
218
+
219
+ if (stateParam) {
220
+ handleOAuthReturn(props, stateParam);
221
+ }
222
+ }
223
+
224
+ // ---- Main hook -------------------------------------------------------
225
+
226
+ export const useEventLink = (props: EventLinkProps) => {
227
+ // Detect OAuth return on every call. The module-level guard ensures
228
+ // we only actually process a return once per page load, even if the
229
+ // hook is called from multiple components or re-renders.
230
+ detectOAuthReturn(props);
231
+
232
+ const linkWindow = createWindow({ ...props });
233
+
234
+ let messageHandler: ((event: MessageEvent) => void) | null = null;
235
+ let isListenerActive = false;
236
+
237
+ const handleMessage = (event: MessageEvent) => {
238
+ if (typeof window === "undefined") return;
239
+
240
+ const iFrameWindow = document.getElementById(VISIBLE_IFRAME_ID) as HTMLIFrameElement;
241
+ if (!iFrameWindow || iFrameWindow.style.display !== "block") return;
242
+
243
+ // Only accept messages from our iframe instance.
244
+ if (event.source !== iFrameWindow.contentWindow) return;
245
+
246
+ const eventData = (event as unknown as EventProps).data;
247
+ if (!eventData?.messageType) return;
248
+
249
+ // Deduplication: prevent processing same message type within expiry window
250
+ const dedupeKey = `${eventData.messageType}-${JSON.stringify(eventData.message ?? eventData.url ?? "")}`;
251
+ if (processedMessages.has(dedupeKey)) {
252
+ return;
253
+ }
254
+ processedMessages.add(dedupeKey);
255
+ setTimeout(() => processedMessages.delete(dedupeKey), MESSAGE_EXPIRY_MS);
256
+
257
+ switch (eventData.messageType) {
258
+ case "EXIT_EVENT_LINK":
259
+ props.onClose?.();
260
+ setTimeout(() => {
261
+ close();
262
+ }, 200);
263
+ break;
264
+ case "LINK_SUCCESS":
265
+ props.onSuccess?.(eventData.message as ConnectionRecord);
266
+ break;
267
+ case "LINK_ERROR":
268
+ props.onError?.(eventData.message as string);
269
+ break;
270
+ case "OAUTH_REDIRECT": {
271
+ // Same-window OAuth redirect flow. The iframe asks the parent
272
+ // to navigate to the OAuth provider URL. We capture the
273
+ // current page URL, encode it into the OAuth state parameter,
274
+ // tear down the iframe, and navigate.
275
+ const oauthUrl = eventData.url;
276
+ const oauthState = eventData.state;
277
+ if (!oauthUrl || !oauthState) {
278
+ props.onError?.("Invalid OAuth redirect message");
279
+ break;
280
+ }
281
+ const returnUrl = window.location.href;
282
+ const navigateUrl = injectReturnUrlIntoState(
283
+ oauthUrl,
284
+ oauthState,
285
+ returnUrl
286
+ );
287
+
288
+ // Detach our message listener and close the iframe before
289
+ // navigation. We close explicitly (instead of relying on the
290
+ // navigation to destroy it) so we leave a clean DOM state.
291
+ if (messageHandler && isListenerActive) {
292
+ window.removeEventListener("message", messageHandler);
293
+ isListenerActive = false;
294
+ messageHandler = null;
295
+ }
296
+ linkWindow.closeLink();
297
+
298
+ window.location.href = navigateUrl;
299
+ break;
300
+ }
301
+ }
302
+ };
303
+
304
+ const open = () => {
305
+ // Remove existing listener first (defensive)
306
+ if (messageHandler && isListenerActive) {
307
+ window.removeEventListener("message", messageHandler);
308
+ }
309
+
310
+ messageHandler = handleMessage;
311
+
312
+ if (typeof window !== "undefined") {
313
+ window.addEventListener("message", messageHandler);
314
+ isListenerActive = true;
315
+ }
316
+
317
+ linkWindow.openLink();
318
+ };
319
+
320
+ const close = () => {
321
+ // Clean up listener and dedup state when closing
322
+ if (typeof window !== "undefined" && messageHandler && isListenerActive) {
323
+ window.removeEventListener("message", messageHandler);
324
+ isListenerActive = false;
325
+ messageHandler = null;
326
+ }
327
+ // Only clear the EXIT dedup key so re-opening works immediately.
328
+ // LINK_SUCCESS / LINK_ERROR dedup keys stay to prevent duplicate callbacks.
329
+ for (const key of processedMessages) {
330
+ if (key.startsWith("EXIT_EVENT_LINK")) {
331
+ processedMessages.delete(key);
332
+ }
333
+ }
334
+
335
+ linkWindow.closeLink();
336
+ };
337
+
338
+ return { open, close };
339
+ };
@@ -0,0 +1,123 @@
1
+ import { EventLinkWindowProps } from "../types";
2
+
3
+ // Capability flag sent to the auth iframe so it knows the parent
4
+ // supports the same-window OAuth redirect flow. Older iframes that
5
+ // don't read this flag will keep using the popup behavior. Older
6
+ // packages (without this flag) make the iframe fall back to popups.
7
+ const PACKAGE_CAPABILITIES = {
8
+ oauthRedirect: true,
9
+ };
10
+
11
+ // DOM ID for the auth iframe. Constant so the package can find and
12
+ // remove its own iframe deterministically across re-mounts and BFCache
13
+ // edge cases.
14
+ export const VISIBLE_IFRAME_ID = "event-link";
15
+
16
+ export class EventLinkWindow {
17
+ private linkTokenEndpoint: string;
18
+ private linkHeaders?: object;
19
+ private baseUrl?: string;
20
+ private onClose?: () => void;
21
+ private title?: string;
22
+ private imageUrl?: string;
23
+ private companyName?: string;
24
+ private selectedConnection?: string;
25
+ private showNameInput?: boolean;
26
+ private appTheme?: "dark" | "light";
27
+ private checkState?: string;
28
+
29
+ constructor(props: EventLinkWindowProps) {
30
+ this.linkTokenEndpoint = props.token.url;
31
+ this.linkHeaders = props.token.headers;
32
+ this.baseUrl = props.baseUrl;
33
+ this.onClose = props.onClose;
34
+ this.title = props.title;
35
+ this.imageUrl = props.imageUrl;
36
+ this.companyName = props.companyName;
37
+ this.selectedConnection = props.selectedConnection;
38
+ this.showNameInput = props.showNameInput;
39
+ this.appTheme = props.appTheme;
40
+ this.checkState = props.checkState;
41
+ }
42
+
43
+ private _getBaseUrl() {
44
+ if (this.baseUrl) {
45
+ return this.baseUrl;
46
+ }
47
+ return "https://auth.withone.ai";
48
+ }
49
+
50
+ private _buildPayload() {
51
+ return {
52
+ linkTokenEndpoint: this.linkTokenEndpoint,
53
+ linkHeaders: this.linkHeaders,
54
+ title: this.title,
55
+ imageUrl: this.imageUrl,
56
+ companyName: this.companyName,
57
+ selectedConnection: this.selectedConnection,
58
+ showNameInput: this.showNameInput,
59
+ appTheme: this.appTheme,
60
+ // Internal — tells the iframe what the parent supports
61
+ capabilities: PACKAGE_CAPABILITIES,
62
+ // Internal — when present, iframe goes straight to status check
63
+ checkState: this.checkState,
64
+ };
65
+ }
66
+
67
+ public openLink() {
68
+ // Defensive: if a previous instance is still in the DOM (e.g.,
69
+ // from a double open() call), remove it first. Two iframes with
70
+ // the same id would both receive postMessages and cause weird
71
+ // race conditions.
72
+ const existing = document.getElementById(VISIBLE_IFRAME_ID);
73
+ if (existing) {
74
+ existing.remove();
75
+ }
76
+
77
+ const container = document.createElement("iframe");
78
+
79
+ const payload = this._buildPayload();
80
+ const jsonString = JSON.stringify(payload);
81
+
82
+ const base64Encoded = btoa(jsonString);
83
+ const urlParams = { data: base64Encoded };
84
+ const queryString = new URLSearchParams(urlParams).toString();
85
+
86
+ const url = `${this._getBaseUrl()}?${queryString}`;
87
+
88
+ document.body.appendChild(container);
89
+ container.style.height = "100%";
90
+ container.style.width = "100%";
91
+ container.style.position = "fixed";
92
+ container.style.display = "hidden";
93
+ container.style.visibility = "hidden";
94
+ container.style.zIndex = "9999";
95
+ container.style.backgroundColor = "transparent";
96
+ container.style.inset = "0px";
97
+ container.style.borderWidth = "0px";
98
+ container.id = VISIBLE_IFRAME_ID;
99
+ container.style.overflow = "hidden auto";
100
+ container.src = url;
101
+
102
+ container.onload = () => {
103
+ setTimeout(() => {
104
+ container.style.display = "block";
105
+ container.style.visibility = "visible";
106
+ }, 100);
107
+ container.contentWindow?.postMessage(payload, url);
108
+ };
109
+ }
110
+
111
+ public closeLink() {
112
+ const iFrameWindow = document.getElementById(
113
+ VISIBLE_IFRAME_ID
114
+ ) as HTMLIFrameElement;
115
+ if (iFrameWindow) {
116
+ iFrameWindow.remove();
117
+ }
118
+ }
119
+ }
120
+
121
+ export const createWindow = (props: EventLinkWindowProps) => {
122
+ return new EventLinkWindow(props);
123
+ };