@dreamshive/better-auth-tauri 0.1.0

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/src/client.ts ADDED
@@ -0,0 +1,491 @@
1
+ import type {
2
+ BetterAuthClientPlugin,
3
+ ClientStore,
4
+ } from "better-auth/client";
5
+ import {
6
+ parseSetCookieHeader,
7
+ SECURE_COOKIE_PREFIX,
8
+ stripSecureCookiePrefix,
9
+ } from "better-auth/cookies";
10
+ import { createSchemeURL, getOrigin, isTauriRuntime, safeJSONParse } from "./utils";
11
+ import { setupTauriFocusManager } from "./focus-manager";
12
+ import { setupTauriOnlineManager } from "./online-manager";
13
+ import { PACKAGE_VERSION } from "./version";
14
+
15
+ /* -------------------------------------------------------------------------- */
16
+ /* Types */
17
+ /* -------------------------------------------------------------------------- */
18
+
19
+ export interface TauriClientStorage {
20
+ getItem: (key: string) => string | null | Promise<string | null>;
21
+ setItem: (key: string, value: string) => void | Promise<void>;
22
+ }
23
+
24
+ export interface TauriClientOptions {
25
+ /**
26
+ * The custom URI scheme registered for the Tauri app in `tauri.conf.json`
27
+ * under `plugins.deep-link.desktop.schemes`.
28
+ *
29
+ * Example: `"sokudo"` → produces `sokudo://` deep links.
30
+ */
31
+ scheme: string;
32
+
33
+ /**
34
+ * Persistent key/value storage for the local cookie jar and session cache.
35
+ * Provide an adapter around `@tauri-apps/plugin-store`, a keychain plugin,
36
+ * or `localStorage` for dev.
37
+ */
38
+ storage: TauriClientStorage;
39
+
40
+ /**
41
+ * Prefix for keys written to `storage`.
42
+ * @default "better-auth"
43
+ */
44
+ storagePrefix?: string;
45
+
46
+ /**
47
+ * The cookie-name prefix used by the Better Auth server. Used to filter
48
+ * the server's `Set-Cookie` header so third-party cookies (Cloudflare,
49
+ * analytics, etc.) don't trigger session refetches.
50
+ *
51
+ * Pass multiple prefixes if the server sets several (e.g. when you
52
+ * customize cookie names per instance).
53
+ *
54
+ * @default "better-auth"
55
+ */
56
+ cookiePrefix?: string | string[];
57
+
58
+ /** Disable the local `/get-session` response cache. */
59
+ disableCache?: boolean;
60
+
61
+ /**
62
+ * Refetch the session when the Tauri window regains focus.
63
+ * @default true
64
+ */
65
+ refetchOnWindowFocus?: boolean;
66
+
67
+ /**
68
+ * Refetch the session when the network comes back online.
69
+ * @default true
70
+ */
71
+ refetchOnReconnect?: boolean;
72
+ }
73
+
74
+ interface StoredCookie {
75
+ value: string;
76
+ expires: string | null;
77
+ }
78
+
79
+ /* -------------------------------------------------------------------------- */
80
+ /* Cookie jar helpers */
81
+ /* -------------------------------------------------------------------------- */
82
+
83
+ function getSetCookie(header: string, prevCookie?: string | undefined) {
84
+ const parsed = parseSetCookieHeader(header);
85
+ const toSetCookie = safeJSONParse<Record<string, StoredCookie>>(prevCookie) ?? {};
86
+ parsed.forEach((cookie, key) => {
87
+ const expiresAt = cookie["expires"];
88
+ const maxAge = cookie["max-age"];
89
+ if (maxAge !== undefined && Number(maxAge) <= 0) {
90
+ delete toSetCookie[key];
91
+ return;
92
+ }
93
+ const expires = maxAge
94
+ ? new Date(Date.now() + Number(maxAge) * 1000)
95
+ : expiresAt
96
+ ? new Date(String(expiresAt))
97
+ : null;
98
+ if (expires && expires.getTime() <= Date.now()) {
99
+ delete toSetCookie[key];
100
+ return;
101
+ }
102
+ toSetCookie[key] = {
103
+ value: cookie["value"],
104
+ expires: expires ? expires.toISOString() : null,
105
+ };
106
+ });
107
+ return JSON.stringify(toSetCookie);
108
+ }
109
+
110
+ function getCookie(cookie: string): string {
111
+ const parsed = safeJSONParse<Record<string, StoredCookie>>(cookie) ?? {};
112
+ return Object.entries(parsed).reduce((acc, [key, value]) => {
113
+ if (value.expires && new Date(value.expires) < new Date()) return acc;
114
+ return acc ? `${acc}; ${key}=${value.value}` : `${key}=${value.value}`;
115
+ }, "");
116
+ }
117
+
118
+ function getOAuthStateValue(
119
+ cookieJson: string | null,
120
+ cookiePrefix: string | string[],
121
+ ): string | null {
122
+ if (!cookieJson) return null;
123
+ const parsed = safeJSONParse<Record<string, StoredCookie>>(cookieJson);
124
+ if (!parsed) return null;
125
+
126
+ const prefixes = Array.isArray(cookiePrefix) ? cookiePrefix : [cookiePrefix];
127
+ for (const prefix of prefixes) {
128
+ for (const name of [
129
+ `${SECURE_COOKIE_PREFIX}${prefix}.oauth_state`,
130
+ `${prefix}.oauth_state`,
131
+ ]) {
132
+ const value = parsed?.[name]?.value;
133
+ if (value) return value;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /** Only notify `$sessionSignal` when the session cookie values actually changed. */
140
+ function hasSessionCookieChanged(
141
+ prevCookie: string | null,
142
+ newCookie: string,
143
+ ): boolean {
144
+ if (!prevCookie) return true;
145
+ try {
146
+ const prev = JSON.parse(prevCookie) as Record<string, StoredCookie>;
147
+ const next = JSON.parse(newCookie) as Record<string, StoredCookie>;
148
+ const keys = new Set<string>();
149
+ for (const k of Object.keys(prev)) {
150
+ if (k.includes("session_token") || k.includes("session_data")) keys.add(k);
151
+ }
152
+ for (const k of Object.keys(next)) {
153
+ if (k.includes("session_token") || k.includes("session_data")) keys.add(k);
154
+ }
155
+ for (const k of keys) {
156
+ if (prev[k]?.value !== next[k]?.value) return true;
157
+ }
158
+ return false;
159
+ } catch {
160
+ return true;
161
+ }
162
+ }
163
+
164
+ /** Is this Set-Cookie header ours, or third-party (Cloudflare, analytics)? */
165
+ function hasBetterAuthCookies(
166
+ setCookieHeader: string,
167
+ cookiePrefix: string | string[],
168
+ ): boolean {
169
+ const cookies = parseSetCookieHeader(setCookieHeader);
170
+ const prefixes = Array.isArray(cookiePrefix) ? cookiePrefix : [cookiePrefix];
171
+ const suffixes = ["session_token", "session_data"];
172
+ for (const name of cookies.keys()) {
173
+ const bare = stripSecureCookiePrefix(name);
174
+ for (const prefix of prefixes) {
175
+ if (prefix) {
176
+ if (bare.startsWith(prefix)) return true;
177
+ } else {
178
+ for (const s of suffixes) if (bare.endsWith(s)) return true;
179
+ }
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+
185
+ /** Some storage backends (keychains) reject `:` in keys. */
186
+ function normalizeKey(name: string): string {
187
+ return name.replace(/:/g, "_");
188
+ }
189
+
190
+ function wrapStorage(storage: TauriClientStorage) {
191
+ return {
192
+ async getItem(name: string): Promise<string | null> {
193
+ const v = await storage.getItem(normalizeKey(name));
194
+ return v ?? null;
195
+ },
196
+ async setItem(name: string, value: string): Promise<void> {
197
+ await storage.setItem(normalizeKey(name), value);
198
+ },
199
+ };
200
+ }
201
+
202
+ /* -------------------------------------------------------------------------- */
203
+ /* Deep-link OAuth flow */
204
+ /* -------------------------------------------------------------------------- */
205
+
206
+ /**
207
+ * Open a URL in the user's default system browser and wait for the
208
+ * `{scheme}://` deep-link callback. Resolves with the full callback URL
209
+ * (which includes `?cookie=...` thanks to the server plugin's `after` hook).
210
+ */
211
+ async function openAuthSession(
212
+ urlToOpen: string,
213
+ scheme: string,
214
+ ): Promise<string> {
215
+ // Peer-deps are resolved lazily so the package can be imported in a
216
+ // non-Tauri context (e.g. `bun dev` in a browser) without a hard failure.
217
+ const [{ open: openShell }, { onOpenUrl }] = await Promise.all([
218
+ import("@tauri-apps/plugin-shell"),
219
+ import("@tauri-apps/plugin-deep-link"),
220
+ ]);
221
+
222
+ const callback = new Promise<string>((resolve, reject) => {
223
+ let settled = false;
224
+ const timeout = setTimeout(
225
+ () => {
226
+ if (settled) return;
227
+ settled = true;
228
+ reject(new Error("OAuth timed out — no deep-link callback received"));
229
+ },
230
+ 5 * 60 * 1000,
231
+ );
232
+
233
+ // `onOpenUrl` returns a Promise<UnlistenFn>. We unlisten as soon as we get
234
+ // a URL that matches our scheme so we don't leak event subscriptions.
235
+ const unlistenPromise = onOpenUrl((urls: string[]) => {
236
+ const match = urls.find((u) => u.startsWith(`${scheme}://`));
237
+ if (!match) return;
238
+ if (settled) return;
239
+ settled = true;
240
+ clearTimeout(timeout);
241
+ unlistenPromise.then((fn) => fn()).catch(() => {});
242
+ resolve(match);
243
+ });
244
+ });
245
+
246
+ await openShell(urlToOpen);
247
+ return callback;
248
+ }
249
+
250
+ /* -------------------------------------------------------------------------- */
251
+ /* Client plugin */
252
+ /* -------------------------------------------------------------------------- */
253
+
254
+ export const tauriClient = (opts: TauriClientOptions) => {
255
+ if (!opts.scheme) {
256
+ throw new Error(
257
+ "[better-auth-tauri] `scheme` is required. Pass the custom URI scheme " +
258
+ "you registered in tauri.conf.json (e.g. 'sokudo').",
259
+ );
260
+ }
261
+
262
+ const storagePrefix = opts.storagePrefix || "better-auth";
263
+ const cookieName = `${storagePrefix}_cookie`;
264
+ const localCacheName = `${storagePrefix}_session_data`;
265
+ const cookiePrefix = opts.cookiePrefix || "better-auth";
266
+ const storage = wrapStorage(opts.storage);
267
+ const scheme = opts.scheme;
268
+ const refetchOnWindowFocus = opts.refetchOnWindowFocus !== false;
269
+ const refetchOnReconnect = opts.refetchOnReconnect !== false;
270
+
271
+ let store: ClientStore | null = null;
272
+
273
+ return {
274
+ id: "tauri",
275
+ getActions(_, $store) {
276
+ store = $store;
277
+
278
+ // Wire up focus + online refetch managers once the store is
279
+ // available. No-op outside Tauri runtime. These fire-and-forget —
280
+ // the subscriptions live for the process lifetime.
281
+ if (isTauriRuntime() && store) {
282
+ if (refetchOnWindowFocus) {
283
+ setupTauriFocusManager(store).catch(() => {});
284
+ }
285
+ if (refetchOnReconnect) {
286
+ setupTauriOnlineManager(store);
287
+ }
288
+ }
289
+ return {
290
+ /**
291
+ * Returns the currently-stored cookie string in the standard
292
+ * `key=value; key2=value2` format — useful if you need to attach it
293
+ * to a custom fetch outside of the Better Auth client.
294
+ */
295
+ getCookie: async () => {
296
+ const raw = await storage.getItem(cookieName);
297
+ return getCookie(raw || "{}");
298
+ },
299
+ };
300
+ },
301
+ fetchPlugins: [
302
+ {
303
+ id: "tauri",
304
+ name: "Tauri",
305
+ hooks: {
306
+ async onSuccess(context) {
307
+ // In a plain-browser dev environment the default cookie flow
308
+ // works fine — skip the deep-link machinery.
309
+ if (!isTauriRuntime()) return;
310
+
311
+ const setCookieHeader = context.response.headers.get("set-cookie");
312
+ if (setCookieHeader) {
313
+ if (hasBetterAuthCookies(setCookieHeader, cookiePrefix)) {
314
+ const prev = await storage.getItem(cookieName);
315
+ const next = getSetCookie(setCookieHeader, prev ?? undefined);
316
+ await storage.setItem(cookieName, next);
317
+ if (hasSessionCookieChanged(prev, next)) {
318
+ store?.notify("$sessionSignal");
319
+ }
320
+ }
321
+ }
322
+
323
+ if (
324
+ context.request.url.toString().includes("/get-session") &&
325
+ !opts.disableCache
326
+ ) {
327
+ await storage.setItem(localCacheName, JSON.stringify(context.data));
328
+ }
329
+
330
+ // Detect social / generic-oauth sign-in responses that include
331
+ // an authorization URL. We check the URL instead of the
332
+ // `redirect` flag because our init hook sets
333
+ // `disableRedirect: true` on these requests (to stop Better
334
+ // Auth's Vue client from window.location-navigating the Tauri
335
+ // webview), which causes the server to respond with
336
+ // `redirect: false`. The presence of `url` is the reliable
337
+ // signal that this is an OAuth start response.
338
+ const requestURL = context.request.url.toString();
339
+ const isSignInRedirect =
340
+ typeof context.data?.url === "string" &&
341
+ (requestURL.includes("/sign-in/social") ||
342
+ requestURL.includes("/sign-in/oauth2") ||
343
+ requestURL.includes("/link-social"));
344
+
345
+ if (!isSignInRedirect) return;
346
+
347
+ const bodyStr =
348
+ typeof context.request?.body === "string"
349
+ ? context.request.body
350
+ : JSON.stringify(context.request?.body ?? {});
351
+ if (bodyStr.includes("idToken")) return; // silent native flow
352
+
353
+ const signInURL = context.data.url as string;
354
+
355
+ // Prevent Better Auth's Vue/React client from auto-navigating
356
+ // the Tauri webview to the OAuth URL via window.location.href.
357
+ // On web this is the correct default; in a Tauri webview it
358
+ // would take over the app's own window with the provider's
359
+ // login page. We open the system browser ourselves below.
360
+ context.data.redirect = false;
361
+ context.data.url = undefined;
362
+
363
+ // Route the system-browser navigation through the server-side
364
+ // `tauriAuthorizationProxy` endpoint so the OAuth `state` cookie
365
+ // gets planted in the browser's cookie jar before the provider
366
+ // redirect. Without this, Better Auth's callback state check
367
+ // fails because the state cookie was set on the /sign-in/social
368
+ // response inside the Tauri webview — a different cookie jar.
369
+ //
370
+ // If we have a previously-stored `oauth_state` (from an earlier
371
+ // sign-in attempt), forward it to the proxy so it can be
372
+ // re-seeded instead of re-derived from the URL.
373
+ const storedCookieJson = await storage.getItem(cookieName);
374
+ const oauthStateValue = getOAuthStateValue(
375
+ storedCookieJson,
376
+ cookiePrefix,
377
+ );
378
+ const params = new URLSearchParams({
379
+ authorizationURL: signInURL,
380
+ });
381
+ if (oauthStateValue) {
382
+ params.append("oauthState", oauthStateValue);
383
+ }
384
+ const proxyURL = `${context.request.baseURL}/tauri-authorization-proxy?${params.toString()}`;
385
+
386
+ try {
387
+ const callbackURL = await openAuthSession(proxyURL, scheme);
388
+ const parsed = new URL(callbackURL);
389
+ const cookie = parsed.searchParams.get("cookie");
390
+ if (!cookie) return;
391
+ const prev = await storage.getItem(cookieName);
392
+ const next = getSetCookie(cookie, prev ?? undefined);
393
+ await storage.setItem(cookieName, next);
394
+ store?.notify("$sessionSignal");
395
+ } catch (err) {
396
+ // Re-throw so the caller of signIn.social() sees the failure.
397
+ throw err;
398
+ }
399
+ },
400
+ },
401
+ async init(url, options) {
402
+ if (!isTauriRuntime()) {
403
+ return { url, options };
404
+ }
405
+
406
+ options = options || {};
407
+ options.credentials = "omit";
408
+
409
+ // Native ID-token flow (e.g. Sign in with Apple) doesn't need
410
+ // cookie/origin handling — the token is verified server-side.
411
+ const isIdTokenRequest =
412
+ (options.body as Record<string, unknown> | undefined)?.idToken !==
413
+ undefined;
414
+
415
+ if (isIdTokenRequest) {
416
+ options.headers = {
417
+ ...options.headers,
418
+ "x-skip-oauth-proxy": "true",
419
+ };
420
+ } else {
421
+ const stored = await storage.getItem(cookieName);
422
+ const cookie = getCookie(stored || "{}");
423
+ options.headers = {
424
+ ...options.headers,
425
+ // "Cookie" is a forbidden header name per the Fetch spec
426
+ // and gets silently dropped by the Headers constructor
427
+ // (which runs inside `new Request(...)` before the request
428
+ // ever reaches Rust / tauriFetch). Smuggle the value under
429
+ // a custom header and let the server plugin rewrite it to
430
+ // "Cookie" inside onRequest — round-trip equivalent.
431
+ ...(cookie ? { "x-tauri-cookie": cookie } : {}),
432
+ "tauri-origin": getOrigin(scheme),
433
+ "x-skip-oauth-proxy": "true",
434
+ };
435
+
436
+ // Rewrite any relative callbackURL the caller passed as a path
437
+ // (e.g. "/") to the Tauri-facing custom-scheme deep link, so
438
+ // Better Auth's final redirect lands on our deep-link handler.
439
+ const body = options.body as Record<string, unknown> | undefined;
440
+ if (body) {
441
+ for (const key of [
442
+ "callbackURL",
443
+ "newUserCallbackURL",
444
+ "errorCallbackURL",
445
+ ] as const) {
446
+ const value = body[key];
447
+ if (typeof value === "string" && value.startsWith("/")) {
448
+ body[key] = createSchemeURL(value, scheme);
449
+ }
450
+ }
451
+
452
+ // For social/oauth sign-in routes, force `disableRedirect`
453
+ // so Better Auth's Vue/React client does NOT navigate the
454
+ // Tauri webview to the OAuth provider URL. We open the
455
+ // system browser ourselves in the onSuccess hook below.
456
+ // Without this, the webview takes over with the provider's
457
+ // login page and the user's own app UI is lost.
458
+ if (
459
+ url.includes("/sign-in/social") ||
460
+ url.includes("/sign-in/oauth2") ||
461
+ url.includes("/link-social")
462
+ ) {
463
+ body.disableRedirect = true;
464
+ }
465
+ }
466
+
467
+ // Clear local state on sign-out so the app immediately reflects
468
+ // the logged-out state.
469
+ if (url.includes("/sign-out")) {
470
+ await storage.setItem(cookieName, "{}");
471
+ await storage.setItem(localCacheName, "{}");
472
+ store?.atoms?.session?.set({
473
+ ...(store.atoms.session.get() as object),
474
+ data: null,
475
+ error: null,
476
+ isPending: false,
477
+ } as never);
478
+ }
479
+ }
480
+
481
+ return { url, options };
482
+ },
483
+ },
484
+ ],
485
+ } satisfies BetterAuthClientPlugin;
486
+ };
487
+
488
+ export { PACKAGE_VERSION } from "./version";
489
+ export type { BetterAuthClientPlugin };
490
+ export { setupTauriFocusManager } from "./focus-manager";
491
+ export { setupTauriOnlineManager } from "./online-manager";
@@ -0,0 +1,36 @@
1
+ import type { ClientStore } from "better-auth/client";
2
+
3
+ /**
4
+ * Refetch Better Auth's session whenever the Tauri window regains focus.
5
+ *
6
+ * Rationale: if the user signs out in another window, or their session
7
+ * expires while the app is backgrounded, we want the next time they come
8
+ * back to the app to reflect reality. Without a focus listener the local
9
+ * session store stays stale until some other request triggers a refresh.
10
+ *
11
+ * Subscribes to `@tauri-apps/api/window#onFocusChanged`. On `focused ===
12
+ * true`, we notify `$sessionSignal` which every `useSession` subscriber
13
+ * listens for — causing them to refetch `/get-session`.
14
+ *
15
+ * Returns an unlisten function. Cleanup is the caller's responsibility
16
+ * (typically the plugin's lifecycle handles this implicitly — when the
17
+ * app quits, the listener dies with the process).
18
+ */
19
+ export async function setupTauriFocusManager(
20
+ store: ClientStore,
21
+ ): Promise<() => void> {
22
+ try {
23
+ const { getCurrentWindow } = await import("@tauri-apps/api/window");
24
+ const win = getCurrentWindow();
25
+ const unlisten = await win.onFocusChanged(({ payload: focused }) => {
26
+ if (focused) {
27
+ store.notify("$sessionSignal");
28
+ }
29
+ });
30
+ return unlisten;
31
+ } catch {
32
+ // @tauri-apps/api/window isn't available (e.g. running in a plain
33
+ // browser during `bun dev`). Return a no-op cleanup.
34
+ return () => {};
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { createAuthMiddleware } from "better-auth/api";
2
+ import type { BetterAuthPlugin } from "better-auth";
3
+ import { tauriAuthorizationProxy } from "./routes";
4
+
5
+ export interface TauriOptions {
6
+ /**
7
+ * Disable the origin header override for Tauri requests.
8
+ *
9
+ * Normally the plugin maps the `tauri-origin` header (sent by the client
10
+ * plugin) to the `origin` header so the server's trusted-origin checks
11
+ * accept the custom URI scheme. Set this to `true` if you want to handle
12
+ * origin validation yourself.
13
+ */
14
+ disableOriginOverride?: boolean | undefined;
15
+ }
16
+
17
+ /**
18
+ * Server-side Better Auth plugin for Tauri desktop apps.
19
+ *
20
+ * What it does:
21
+ * 1. Remaps `tauri-origin` → `origin` so the Better Auth CSRF / trusted-origin
22
+ * check sees a value the server is configured to accept (e.g. `sokudo://`).
23
+ * 2. Intercepts OAuth callback redirects whose `location` targets a non-HTTP
24
+ * custom scheme (the Tauri app's URI scheme). Before the 302 leaves the
25
+ * server, it appends the freshly-set `Set-Cookie` header as a `cookie`
26
+ * query parameter. The Tauri app then reads the cookie out of the
27
+ * deep-link URL and stores it locally — bridging the browser ↔ app
28
+ * cookie jars that the OS keeps isolated.
29
+ *
30
+ * Pair this with `tauriClient` from `@dreamshive/better-auth-tauri/client`.
31
+ */
32
+ export const tauri = (options?: TauriOptions): BetterAuthPlugin => {
33
+ return {
34
+ id: "tauri",
35
+ init: () => {
36
+ // In development we trust the common tauri dev origin. The real app
37
+ // scheme (e.g. `sokudo://`) should be added explicitly by the host
38
+ // app in its `trustedOrigins` — we don't assume a scheme here.
39
+ const trustedOrigins =
40
+ typeof process !== "undefined" &&
41
+ process.env?.NODE_ENV === "development"
42
+ ? ["tauri://localhost"]
43
+ : [];
44
+ return {
45
+ options: {
46
+ trustedOrigins,
47
+ },
48
+ };
49
+ },
50
+ async onRequest(request, _ctx) {
51
+ const tauriOrigin = request.headers.get("tauri-origin");
52
+ // `Cookie` is a forbidden header name on the client side (browsers
53
+ // / WKWebView strip it before sending), so the client plugin
54
+ // smuggles it as `x-tauri-cookie`. We move it back to `Cookie`
55
+ // here so downstream Better Auth handlers see the session cookie.
56
+ const smuggledCookie = request.headers.get("x-tauri-cookie");
57
+
58
+ const shouldRewriteOrigin =
59
+ !options?.disableOriginOverride &&
60
+ !request.headers.get("origin") &&
61
+ !!tauriOrigin;
62
+
63
+ if (!shouldRewriteOrigin && !smuggledCookie) return;
64
+
65
+ const applyRewrites = (headers: Headers) => {
66
+ if (shouldRewriteOrigin && tauriOrigin) {
67
+ headers.set("origin", tauriOrigin);
68
+ }
69
+ if (smuggledCookie) {
70
+ headers.set("cookie", smuggledCookie);
71
+ headers.delete("x-tauri-cookie");
72
+ }
73
+ };
74
+
75
+ try {
76
+ // Prefer in-place mutation (works on Bun, Node, Deno).
77
+ applyRewrites(request.headers);
78
+ return { request };
79
+ } catch {
80
+ // Some runtimes (e.g. Cloudflare Workers) have immutable request
81
+ // headers — fall back to constructing a new Request.
82
+ const newHeaders = new Headers(request.headers);
83
+ applyRewrites(newHeaders);
84
+ return { request: new Request(request, { headers: newHeaders }) };
85
+ }
86
+ },
87
+ hooks: {
88
+ after: [
89
+ {
90
+ matcher(context) {
91
+ return !!(
92
+ context.path?.startsWith("/callback") ||
93
+ context.path?.startsWith("/oauth2/callback") ||
94
+ context.path?.startsWith("/magic-link/verify") ||
95
+ context.path?.startsWith("/verify-email")
96
+ );
97
+ },
98
+ handler: createAuthMiddleware(async (ctx) => {
99
+ const headers = ctx.context.responseHeaders;
100
+ const location = headers?.get("location");
101
+ if (!location) return;
102
+
103
+ // Leave Better Auth's own oauth-proxy plugin redirects alone —
104
+ // those go through a separate round-trip we shouldn't rewrite.
105
+ if (location.includes("/oauth-proxy-callback")) return;
106
+
107
+ let redirectURL: URL;
108
+ try {
109
+ redirectURL = new URL(location);
110
+ } catch {
111
+ return;
112
+ }
113
+
114
+ // Only rewrite redirects going to custom schemes — leave HTTP(S)
115
+ // redirects alone (those are the in-browser / web flow).
116
+ const isHttpRedirect =
117
+ redirectURL.protocol === "http:" ||
118
+ redirectURL.protocol === "https:";
119
+ if (isHttpRedirect) return;
120
+
121
+ if (!ctx.context.isTrustedOrigin(location)) return;
122
+
123
+ const cookie = headers?.get("set-cookie");
124
+ if (!cookie) return;
125
+
126
+ redirectURL.searchParams.set("cookie", cookie);
127
+ ctx.setHeader("location", redirectURL.toString());
128
+ }),
129
+ },
130
+ ],
131
+ },
132
+ endpoints: {
133
+ tauriAuthorizationProxy,
134
+ },
135
+ options,
136
+ } satisfies BetterAuthPlugin;
137
+ };
138
+
139
+ export { PACKAGE_VERSION } from "./version";
140
+ export { tauriAuthorizationProxy } from "./routes";
@@ -0,0 +1,29 @@
1
+ import type { ClientStore } from "better-auth/client";
2
+
3
+ /**
4
+ * Refetch the session when the network comes back online. Useful after
5
+ * suspend/resume or a flaky wifi transition — otherwise the session
6
+ * store would keep showing the cached pre-disconnect state until some
7
+ * other request tries (and fails) to reach the server.
8
+ *
9
+ * Uses the browser's standard `online` event, which the Tauri WebView
10
+ * exposes the same way Chrome/Safari do. No Tauri plugin needed — the
11
+ * OS-level network state flows through the webview's navigator.
12
+ *
13
+ * Returns an unlisten function.
14
+ */
15
+ export function setupTauriOnlineManager(store: ClientStore): () => void {
16
+ if (typeof window === "undefined") {
17
+ return () => {};
18
+ }
19
+
20
+ const handleOnline = () => {
21
+ store.notify("$sessionSignal");
22
+ };
23
+
24
+ window.addEventListener("online", handleOnline);
25
+
26
+ return () => {
27
+ window.removeEventListener("online", handleOnline);
28
+ };
29
+ }