#Implement Oauth with Supabase in Tauri V2 with Deep Link

24 messages · Page 1 of 1 (latest)

spiral swallow
#

Hello, is the deep-link plugin only available for mobile applications in V2?

I have a desktop app where I want to implement OAuth functionalities, and I was recommended by Fabian (I think) to use the deep-link plugin. Someone also shared their code to do it without the plugin, but I am unsure if it will work with Google and GitHub.

The issue is that I’m using a proxy, which Simon recommended I create. I decided to build it with Hono, and the backend is Supabase. The idea is to use the PKCE flow for OAuth. However, whether using deep-links or another method, I find it challenging to understand how to implement this flow. The API already has the authentication endpoints working, and I’ve managed to log in using Google, GitHub, or even email and password with some HTTP client. But I struggle to understand how to do it with Tauri.

My main idea was to use the user’s browser to connect to the app, which is why I was recommended to use deep-links. Another person recommended code that worked for V1, and I managed to partially understand how to implement it in Tauri V2. This code opened a new window in the app. Still, I am quite confused about how to securely and properly get my API to communicate with my app, especially with deep-links since I don’t understand how domains work, how I should configure the API, or the schemas I need to set in the Tauri configuration.

I found this PR indicating that the Deep Link plugin can be used with desktop applications. However, the documentation states that it only supports iOS and Android.

Tauri

Set your Tauri application as the default handler for an URL.

#

For now, I’ve configured it very basically like this. If I didn’t add the mobile field, the app wouldn't compile.

"plugins": {
    "deep-link": {
      "mobile": [
        { "host": "cuervolu.dev", "pathPrefix": ["/intent"] },
        { "host": "tauri.app" }
      ],
      "desktop": {
        "schemes": ["cuervolu", "my-tauri-app"]
      }
    }
  }

I have to say that I don’t quite understand what “schemes” are. I added some random info for now. My main understanding is that they are related to the capabilities we have, right? I have the default.json. As I understood from the previously mentioned PR, if I wanted it to happen in a single instance, I had to add that plugin plus this:

use tauri::{Manager};

#[derive(Clone, serde::Serialize)]
struct Payload {
    args: Vec<String>,
    cwd: String,
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    #[cfg(debug_assertions)]
    let devtools = tauri_plugin_devtools::init();

    let mut builder = tauri::Builder::default();
    #[cfg(debug_assertions)]
    {
        builder = builder.plugin(devtools);
    }

    builder.plugin(tauri_plugin_deep_link::init())
        .plugin(tauri_plugin_single_instance::init(|app, argv, cwd| {
            println!("{}, {argv:?}, {cwd}", app.package_info().name);

            app.emit("single-instance", Payload { args: argv, cwd }).unwrap();
        }))
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
grizzled knot
#

I found this PR indicating that the Deep Link plugin can be used with desktop applications. However, the documentation states that it only supports iOS and Android.
Because the pr was merged earlier than intended and i didn't get to the docs yet 🙃

If I didn’t add the mobile field, the app wouldn't compile.
Yeah, that's my bad but i think it can be an empty array or something.

I have to say that I don’t quite understand what “schemes” are.
For example in https://google.com https is the scheme. For desktop deep links you typically use custom schemes / custom urls. So basically in your browser you'll navigate to my-app://some-data?that=we-need and the OS will open the app that's registered for the my-app scheme and give it the url you called.

Completely unrelated to capabilities.

You can pretty much use whatever you want here, just should be unique for most usecases.

spiral swallow
# grizzled knot > I found this PR indicating that the Deep Link plugin can be used with desktop ...

Okay, I tried this in my Hono API:

authRoutes.get('/oauth/callback', async (c) => {
  const code = c.req.query('code');

  if (!code) {
    return c.json({ error: 'No code provided' }, 400);
  }

  const supabase = c.get('supabase');

  try {
    const response = await supabase.auth.exchangeCodeForSession(code);
    const data = response.data;

    if (!data.session) {
      return c.json({ error: 'Failed to retrieve session' }, 500);
    }

    // Deep link URL to pass session data to the Tauri app
    const deepLinkUrl = `spark://auth-callback?access_token=${
      data.session.access_token
    }&refresh_token=${data.session.refresh_token}&user=${encodeURIComponent(
      JSON.stringify(data.user)
    )}`;

    return c.redirect(deepLinkUrl);
  } catch (error) {
    if (error instanceof AuthApiError) {
      return c.json({ error: error.message }, 400);
    }
    return c.json({ error: 'An unexpected error occurred' }, 500);
  }
});

Then, in the Tauri configuration, I added this:

"plugins": {
    "deep-link": {
      "mobile": [],
      "desktop": {
        "schemes": [
          "spark"
        ]
      }
    }
  }

(I also tried with spark://auth-callback)

#

In my Tauri app, I added two test buttons to open the browser and start the login process:

<script lang="ts">
    import Button from '$lib/components/ui/button/button.svelte';
    import { open } from '@tauri-apps/plugin-shell';
    import { onOpenUrl } from '@tauri-apps/plugin-deep-link';

    const redirectToOAuth = async (provider: string) => {
        const authUrl = `http://localhost:3000/api/auth/oauth/${provider}`;

        await open(authUrl);
    };

    const handleOpenUrl = async () => {
        await onOpenUrl((urls) => {
            urls.forEach((url) => {
                console.log('deep link:', url);

                try {
                    const urlObj = new URL(url);

                    const accessToken = urlObj.searchParams.get('access_token');
                    const refreshToken = urlObj.searchParams.get('refresh_token');
                    const userParam = urlObj.searchParams.get('user');

                    let user = null;
                    if (userParam) {
                        user = JSON.parse(decodeURIComponent(userParam));
                    }

                    console.log('Access Token:', accessToken);
                    console.log('Refresh Token:', refreshToken);
                    console.log('User:', user);
                } catch (error) {
                    console.error('Error handling deep link:', error);
                }
            });
        });
    };

    handleOpenUrl();
</script>

<div class="mx-auto grid max-w-[59rem] flex-1 auto-rows-max gap-4">
    <Button on:click={() => redirectToOAuth('google')}>Login with Google</Button>
    <Button on:click={() => redirectToOAuth('github')}>Login with GitHub</Button>
</div>

Is that enough? I also configured my Supabase backend to redirect to the same Tauri scheme, but the app doesn't seem to recognize the link. In fact, my OS (Arch Linux) prompts me to open it, but the app doesn't show anything in the console.

#

Oh damn, i think my platform is not supported...

spiral swallow
#

Just tested it on windows. Same issue

grizzled knot
#

Generally to test deep links you'll have to build and install the app. (though the register js/rust apis may be enough to work around that in dev)

#

That unsupported error is not from a tauri api though is it? I don't see you using any of the platform specific apis here (other than onOpenUrl which won't throw an error)

spiral swallow
grizzled knot
#

Typically the installer bundles register the deep links on the system, not the actual app itself

#

On macOS and mobile this is a hard requirement. On windows and linux we can register them at runtime in the app, we just typically don't recommend it to keep it consistent (funnily enough, runtime registration is required on linux if you ship appimages - yes, deep links are a mess)

spiral swallow
#

Yes, it works. There are still some bugs, but the app does detect the deep link in the production build. However, I noticed that the error I mentioned earlier is shown by Deep Nebula and comes from deep-link.get_current. Could this be why the login isn't working in the app?

What I did was add these fields to check if I was receiving the response:

<div class="mx-auto grid max-w-[59rem] flex-1 auto-rows-max gap-4">
    <Button on:click={() => redirectToOAuth('google')}>Login with Google</Button>
    <Button on:click={() => redirectToOAuth('github')}>Login with GitHub</Button>
</div>

<div class="output">
    <h3>Deep Link Data:</h3>
    <p><strong>Access Token:</strong> {$accessToken}</p>
    <p><strong>Refresh Token:</strong> {$refreshToken}</p>
    <p><strong>User Info:</strong> {$user ? JSON.stringify($user, null, 2) : 'No user data'}</p>
</div>

But it seems like it's not receiving them, even though the deep link does have those fields, as they are fetched from the backend:

const deepLinkUrl = `spark://auth-callback?access_token=${
      data.session.access_token
    }&refresh_token=${data.session.refresh_token}&user=${encodeURIComponent(
      JSON.stringify(data.user)
    )}`;

One thing to mention is that the Google page keeps loading after selecting the account to sign in. Is this normal?

grizzled knot
#

However, I noticed that the error I mentioned earlier is shown by Deep Nebula and comes from deep-link.get_current. Could this be why the login isn't working in the app?
Could be problematic if this error makes it skip the rest of the code in that block.

One thing to mention is that the Google page keeps loading after selecting the account to sign in. Is this normal?
i don't know. Doesn't sound normal but i didn't try it myself ever. And back when i was looking into this stuff it looked like custom schemes aren't supported on desktop (apparently that's not the case, otherwise it shouldn't even show the dialog asking to open the scheme).

spiral swallow
spiral swallow
#

Could be problematic if this error makes it skip the rest of the code in that block.

Indeed, the onOpenUrl function does not allow me to continue with the other steps due to the unsupported platform error.

grizzled knot
# spiral swallow Yep, it's Google that's the issue in that part. How do you recommend proceeding ...

If a provider doesn't accept custom schemes, they typically accept localhost callbacks. I created a minimal plugin for this (actually having google in mind because their docs sounded to me like you couldn't even register custom schemes for desktop apps) https://github.com/FabianLars/tauri-plugin-oauth - note that i have never used this plugin myself but i've not heard (m)any complaints from others.

spiral swallow
# grizzled knot If a provider doesn't accept custom schemes, they typically accept localhost cal...

Thank you very much for the help you’ve given me so far (and for your patience, haha). I have two questions:

  1. How can I implement the OAuth plugin? I saw the examples in the V2 branch and incorporated that example into my app. From what I can see, it starts a server on a certain port underneath, right? What steps should I follow to log in with Google, for example?

  2. Second question, do you have any idea why the "Unsupported Platform" error occurs with the Deep Link plugin? I've been trying to figure out ways to fix it for hours, and honestly, I can’t think of any. I believe it's something on Tauri’s side. Maybe it’s a bug or something. I'll check if the same thing happens on Windows.

grizzled knot
#
  1. Basically only the redirect url changes from the deep link to the localhost url (the plugin will tell you the port it's actually running on. Oauth providers either allow to specify localhost without a port, or allow multiple urls so you can register a few ports).
    On a high level, you start the plugin, then start the auth process (somehow telling it about the port for the redirect_uri parameter), and then wait for the plugin to get the result.
    That reminds me, there's a v1 example for this https://github.com/JeaneC/tauri-oauth-supabase

  2. You said you're calling getCurrent somewhere. That api is only available on mobile and macos so you'll have to OS guard it. onOpenUrl should not invoke that itself as it's basically the same as calling event.listen so idk what's going on exactly.

#

Nevermind about the last part, we are calling getCurrent in onOpenUrl so you'll have to os guard the onOpenUrl (which also only works on mobile and macos)

spiral swallow
#

Thank you, Fabian. I managed to authenticate a user with your OAuth plugin. Deep Link was causing me issues, so I discarded it. I would still like to know if you have any suggestions on how to proceed further. For example, I was thinking of using the Store plugin to save the tokens and user info. However, I have a small doubt: since I'm using a Proxy and not the Supabase client directly, how do you recommend maintaining the session state? Here’s what I’m currently doing:

Since the OAuth plugin port is somewhat random, I pass it as a parameter to my backend to handle the redirection:

const handleOAuthSignIn = async (
  c: Context,
  provider: string,
  port: number
) => {
  const supabase = c.get('supabase');
  const workerUrl = new URL(c.req.url).origin;
  const redirectUrl = `${workerUrl}/api/auth/oauth/callback?port=${port}`;
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider,
    options: { redirectTo: redirectUrl, skipBrowserRedirect: true },
  });

  if (error) {
    return c.json({ error: error.message }, 400);
  }

  return c.redirect(data.url);
};
#

In the callback, it looks for the port and sends the token with the refresh token (requested by a Supabase method called setSession):

authRoutes.get('/oauth/callback', async (c) => {
  const code = c.req.query('code');
  const port = Number.parseInt(c.req.query('port') || '0', 10);

  if (!code || !port) {
    return c.json({ error: 'No code or port provided' }, 400);
  }

  const supabase = c.get('supabase');

  try {
    const response = await supabase.auth.exchangeCodeForSession(code);
    const data = response.data;

    if (!data.session) {
      return c.json({ error: 'Failed to retrieve session' }, 500);
    }

    return c.redirect(
      `http://localhost:${port}/auth?access_token=${data.session.access_token}&refresh_token=${data.session.refresh_token}`
    );
  } catch (error) {
    if (error instanceof AuthApiError) {
      return c.json({ error: error.message }, 400);
    }
    return c.json({ error: 'An unexpected error occurred' }, 500);
  }
});

To have my frontend get the data, I thought about using this setSession method:

authRoutes.post(
  '/set-session',
  zValidator('json', SessionSchema),
  async (c) => {
    const { accessToken: access_token, refreshToken: refresh_token } =
      c.req.valid('json');
    const supabase = c.get('supabase');

    const { data, error } = await supabase.auth.setSession({
      access_token,
      refresh_token,
    });

    if (error) {
      return c.json({ error: error.message }, 400);
    }

    return c.json(data , 200);
  }
);
#

Finally, on the frontend, I receive everything and use the OAuth and HTTP plugin:

<script lang="ts">
  import { invoke } from '@tauri-apps/api/core';
  import { listen } from '@tauri-apps/api/event';
  import { open } from '@tauri-apps/plugin-shell';
  import { fetch } from '@tauri-apps/plugin-http';
  import { onMount } from 'svelte';
  import { writable } from 'svelte/store';
  import type { Session } from '$lib/interfaces/session.interface';
  import { Button } from '$lib/components/ui/button/index.js';

  type Provider = 'google' | 'github';
  type Port = number | null;

  const loading = writable<boolean>(false);
  const email = writable<string>('');
  const port = writable<Port>(null);
  const session_url = 'http://localhost:3000/api/auth/set-session';
  function getLocalHostUrl(port: number): string {
    return `http://localhost:${port}`;
  }

#
  onMount(() => {
    console.log('Refreshing');
    if ($port) return;

    let unlisten: (() => void) | undefined;

    const oauthHandler = async (data: { payload: string }) => {
      port.set(null);
      if (!data.payload) return;

      const url = new URL(data.payload as string);
      const accessToken = new URLSearchParams(url.search).get('access_token');
      const refreshToken = new URLSearchParams(url.search).get('refresh_token');
      if (accessToken && refreshToken) {
        console.log('accessToken', accessToken);

        try {
          const response = await fetch(session_url, {
            method: 'POST',
            body: JSON.stringify({ accessToken, refreshToken }),
            headers: {
              'Content-Type': 'application/json'
            }
          });

          const data = await response.json();
          if (data && data.user && data.user.email) {
            const user = data.user;
            console.log('session', user);
            email.set(user.email);

            // location.reload();
          } else {
            console.log('Unexpected response structure:', data);
          }
        } catch (error) {
          console.error('Error during request:', error);
        }
      }
    };

    listen('oauth://url', oauthHandler).then((u) => {
      unlisten = u;
    });

    let _port: Port = null;
    invoke('plugin:oauth|start').then((portNumber) => {
      port.set(portNumber as number);
      _port = portNumber as number;
    });

    return () => {
      if (unlisten) {
        unlisten();
      }
      if (_port !== null) {
        invoke('plugin:oauth|cancel', { port: _port });
      }
    };
  });

  const onProviderLogin = (provider: Provider) => async () => {
    loading.set(true);
    const url = `http://localhost:3000/api/auth/oauth/$%7Bprovider%7D?port=${$port}`;

    open(url);
  };
</script>