supabase / auth

A JWT based API for managing users and issuing JWT tokens
https://supabase.com/docs/guides/auth
MIT License
1.45k stars 351 forks source link

Using PKCE flow forces you to use your provider client secret when refreshing the provider token #1450

Closed joostwmd closed 6 months ago

joostwmd commented 7 months ago

Bug report

Describe the bug

I am using Spotify as an oAuth provider in my app. I can sign in via the PKCE flow, but i am unable to refresh the spotify oAuth token, following the official spotify documentation.

this is my implementation according to the spotify docs:

async function refreshToken() {
        if (!session?.provider_refresh_token) {
            throw new Error('no session found');
        }

        const client_id = 'my id';
        const client_secret = 'my secret';

        const body = new URLSearchParams({
            grant_type: 'refresh_token',
            refresh_token: session.provider_refresh_token,
            client_id
        });

        const response = await fetch('https://accounts.spotify.com/api/token', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded'
                //Authorization: 'Basic ' + btoa(client_id + ':' + client_secret)
            },
            body: body
        });
        if (response.ok) {
            const data = await response.json();
            console.log('Success:', data);
            return data;
        } else {
            // It's important to catch and handle errors properly
            const errorData = await response.json();
            console.error('Error:', errorData);
            throw errorData;
        }
    }

with this i am getting an error 400 along with an empty error message.

"{error: 'invalid_request', error_description: ''}

After a lot of playing around i found out that setting an Authorization header like this Authorization: 'Basic ' + btoa(client_id + ':' + client_secret) makes it possible to refresh the token. However, doing it like this exposes the client secret in the browser, but the idea behind the about PKCE flow is that you don't have to do that. The spotify documentation also don't mention that you have to pass the client secret in the PKCE flow. But it is required for the normal (no PKCE) Authorization Code flow. This is where i started to suspect that supabase might not use the PKCE flow as intended.

Then i decided to set up the pkce flow without supabase, to check if the error might be on Spotifys side. I cloned this repo and i was able to refresh the token without providing my client secret, so the Spotify API works.

That leaves only supabase in the equation. I think the issue is that when sining in via supabase oAuth, something happens under the hood, that makes the provider think you are not using the PKCE flow. At least that could explain, why i am forced to supply my client secret when refreshing the token later.

To Reproduce

Steps to reproduce the behavior, please provide code snippets or a repository:

I tried it in both sveltekit and react native, both with the same results.

SVELTEKIT

In sveltekit i am initializing the supabase client like this:

import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import type { LayoutLoad } from './$types';
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';

export const load: LayoutLoad = async ({ fetch, data, depends }) => {
    depends('supabase:auth');

    const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
        global: {
            fetch
        },
        cookies: {
            get(key) {
                if (!isBrowser()) {
                    return JSON.stringify(data.session);
                }

                const cookie = parse(document.cookie);
                return cookie[key];
            }
        },
        auth: {
            flowType: 'pkce'
        }
    });

    const {
        data: { session }
    } = await supabase.auth.getSession();

    return { supabase, session };
};

here is the sign in function

 export const signIn = async (supabaseClient: SupabaseClient) => {
    const PUBLIC_BASE_URL = dev ? 'http://localhost:5173' : 'https://onbeat.joostsworld.dev';
    const scopes = [
        'playlist-read-collaborative',
        'playlist-read-private',
        'playlist-modify-public',
        'playlist-modify-private',
        'user-read-email',
        'user-read-private',
        'user-read-playback-position',
        'user-top-read'
    ];
    try {
        const { error, data } = await supabaseClient.auth.signInWithOAuth({
            provider: 'spotify',
            options: {
                redirectTo: `${PUBLIC_BASE_URL}/auth/callback`,
                scopes: scopes.join(' '),
            }
        });
        const code_challenge = new URL(data.url!).searchParams.get('code_challenge');
        if (error) {
            console.error(error);
            return { status: 500, message: 'Failed to sign in' };
        }
        return { status: 200, message: 'Successfully signed in' };
    } catch (err) {
        console.error(err);
        return { status: 500, message: 'An error occurred during sign in' };
    }
};

and here i am extracting the code from the callback url and exchanging it against a token

export const GET = async (event) => {
    //console.log('callback server', event);
    const {
        url,
        locals: { supabase }
    } = event;

    const code = url.searchParams.get('code') as string;

    console.log('code', code);

    if (code) {
        const { error } = await supabase.auth.exchangeCodeForSession(code);
        if (!error) {
            throw redirect(303, '/playlist');
        }
        throw redirect(303, '/playlist');
    }

    // return the user to an error page with instructions
    throw redirect(303, '/');
};

React Native

In my React Native App i am initializing my supabase client like this:

import "react-native-url-polyfill/auto"
import { createClient } from "@supabase/supabase-js"
import { DeviceStorage } from "./DeviceStorage"

const url: string | undefined = process.env.EXPO_PUBLIC_SUPABASE_URL
const key: string | undefined = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY

export const supabase = createClient(url!, key!, {
  auth: {
    storage: DeviceStorage,
    autoRefreshToken: true,
    detectSessionInUrl: false,
    persistSession: true,
    flowType: "pkce",
  },
})

here is the sign in function:

async function handleSocialSignIn(provider: Provider, scopes: string[]) {
    console.log("Initiating login with provider:", provider)

    const redirectTo = AuthSession.makeRedirectUri()

    try {
      const { data, error } = await supabase.auth.signInWithOAuth({
        provider: provider,
        options: {
          redirectTo,
          scopes: scopes.join(" "),
          skipBrowserRedirect: true,
        },
      })

      if (data && data.url) {
        await WebBrowser.openBrowserAsync(data.url, {})
      }

      if (error) throw error
    } catch (error) {
      console.error("Login error:", error)
    }
  }

and here is how i handle the callback url and extract the code + exchange it for a session


  const handleRedirect = useCallback(async (event: any) => {
    const url = new URL(event.url)
    const code = url.searchParams.get("code")

    if (code) {
      const { data, error } = await supabase.auth.exchangeCodeForSession(code)

      if (error) throw error

      const provider_token = data.session?.provider_token
      const provider_refresh_token = data.session?.provider_refresh_token

      if (provider_token && provider_refresh_token) {
        console.log(
          "Storing provider tokens:",
          provider_token,
          provider_refresh_token
        )
        await storeProviderToken(provider_token)
        await storeProviderRefreshToken(provider_refresh_token)
        router.replace("/(protected)/profile")
      } else {
        console.log(
          "no provider tokens found in session response:",
          data.session
        )
      }
      WebBrowser.dismissBrowser()
    }
  }, [])

  useEffect(() => {
    const subscription = Linking.addEventListener("url", (event) =>
      handleRedirect(event)
    )
    return () => subscription.remove()
  }, [handleRedirect, provider])

This is not exactly the way it is shown in the docs, but this is intended.

The Sign In works as expected but calling the refreshToken function during an active session results in the error. I know that it isnt an error from wrong credentials like the client id or refresh token because this produces related error messages, but i am receiving an empty string. Also when adding the Authorizaiton header and changing nothing else, i can refresh the token, so wrong credentials are not the cause of this error

To reproduce the vanilla js PKCE flow, you can just clone this repo and use your client id and url

Expected behavior

Being able to refresh the provider token after sining in via supabase using the PKCE flow

hf commented 6 months ago

Supabase Auth does not deal with provider refresh tokens. You can access them as documented here: https://supabase.com/docs/reference/javascript/auth-onauthstatechange?example=store-provider-tokens

For refreshing the provider tokens, please follow the documentation from Spotify.

henrynguyen7 commented 1 month ago

I'd like to request that this issue be reopened and re-evaluated as I am also running into the issue where attempting to refresh the token with Spotify fails with error "{error: 'invalid_request', error_description: ''} after initially signing on with supabase.auth.signInWithOAuth().

I believe the reason for closing this issue, that Supabase Auth does not deal with provider refresh tokens, is inapplicable because the original reporter is specifically attempting to refresh the token themselves and is not relying on Supabase to do so, so Supabase Auth not dealing with provider refresh tokens is irrelevant.

Furthermore, I can corroborate that adding the client_secret causes the refresh token request to succeed, whereas not including it causes the request to fail with the above error (though I did it by adding a client_secret parameter to the request body, whereas @joostwmd did so by appending it to the Authorization header).

Finally, I can confirm that using the same demo repository above, I am able to successfully refresh the token even without including the client_secret on either the request body or the Authorization header:

request response

This would seem to corroborate the suspicion that somehow, Supabase's OAuth login is not utilizing the PKCE flow correctly, causing Spotify to treat this sign in as an implicit flow rather than a PKCE flow, thus requiring a client_secret to refresh the token.

This is problematic for any browser-based or mobile application that solely uses Supabase for OAuth for Spotify, because such applications cannot safely include the client_secret, which prevents token refreshing entirely. Presumably, other OAuth providers do not suffer from this issue, since it seems like tests are included which verify PKCE functionality (for example, here). However, no such tests are available for Spotify, which would further seem to indicate that it's possibly behaving differently somehow.

Thanks ahead of time for any guidance anyone can provide. 🙏

cc @hf