supabase / auth-helpers

A collection of framework specific Auth utilities for working with Supabase.
https://supabase.github.io/auth-helpers/
MIT License
892 stars 240 forks source link

The SvelteKit SSR Documentation Examples Enhancements #742

Closed kvetoslavnovak closed 2 months ago

kvetoslavnovak commented 4 months ago

Improve documentation

I dived into SvelteKit implementation of Supabase SSR. The SvelteKit documentation may be improved as suggested hereunder..

A link to the page which needs improvement : https://supabase.com/docs/guides/auth/server-side/creating-a-client?framework=sveltekit

Describe the problem

Here is my working tutorial how to implement Supabase Auth SSR in SvelteKit fully. Mainly it gives the missing part how to implement invalidation so auth/session state is in sync between server and browser or between more opened browser tabs.

Feel free to use this tutorial in Supabase website somewhere.

Describe the improvement

EDIT: I HAVILY EDITED MY COMMETS AND CODE EXAMPLES, PUT THE FINAL SOLUTION HERE AT THE TOP AND DELETED PREVIOUS IDEAS TO AVOID POSSIBLE CONFUSION. I HAVE ALSO DELETED A PULL REQUEST IN MATTER UNTIL THIS IS SOLID.

SvelteKit 2 needs a path "/" for a cookie, not ann empty string. Using name createServerClient for server client so not to be confused with browser client.

// src/routes/hooks.server.js
// SvelteKit v2
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';

export const handle = async ({ event, resolve }) => {
    event.locals.supabaseServerClient = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
        cookies: {
            get: (key) => event.cookies.get(key),
            set: (key, value, options) => {
                event.cookies.set(key, value, { ...options, path: '/' });
            },
            remove: (key, options) => {
                event.cookies.delete(key, { ...options, path: '/' });
            }
        }
    });

    const getSessionAndUser = async () => {
        const { data: user, error: err }  = await event.locals.supabaseServerClient.auth.getUser()

      let session
       if (err) {
           return { session, user: null }
       }
       else {
         session = (await event.locals.supabaseServerClient.auth.getSession()).data?.session
      }

        return {session, user}
      }

      const {session, user} = await getSessionAndUser()

      event.locals.session = session
      event.locals.user = user

    return resolve(event, {
        filterSerializedResponseHeaders(name) {
            return name === 'content-range';
        }
    });
};
// src/routes/+layout.server.js
export const load = async (event) => {
    return {
        session: event.locals.session,
        user: event.locals.user
    };
};
// src/routes/+layout.js
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
import { combineChunks, createBrowserClient, isBrowser, parse } from '@supabase/ssr'

export const load = 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 = combineChunks(key, (name) => {
                    const cookies = parse(document.cookie)
                    return cookies[name]
                })
                return cookie
            },
        },
    })

    return {
        supabase,
        session: data.session,
        user: data.user
    }
}

Invalidation call was missing in the documentation but is importatnt to keep the layout and UI in sync.

// src/routes/+layout.svelte
<script>
    import { invalidate, invalidateAll, goto } from '$app/navigation';
    import { onMount } from 'svelte';

    export let data;

    $: ({ supabase } = data);

    onMount(async () => {
        const { data: { subscription } } = supabase.auth.onAuthStateChange((event, _session) => {
            invalidate('supabase:auth');
            // or just use "hard" invalidation refresh
            // invalidateAll();
        });
        return () => subscription.unsubscribe();
    });
</script>

...

Using prefered way to get session and user from locals which is rerun in hooks for every request. This is more secure way compared to gettting the session and user from layout data because layout data are not refreshed everytime. Also it is important to note that server load functions run all at once. If you prefere to use layout data do not forget to call await parent()

//  src/routes/+page.server.js
import { redirect } from "@sveltejs/kit"

export const load = async ({ locals }) => {
    // protected route with redirect if there is no user's session
    if (!locals.session) {
        redirect(303, '/');
    }

    // using Supabase server client which is stored in locals
    // locals.supabaseServerClient. ...

    return {
        session: locals.session,
        user: locals.user
    }
}

// // or if you prefere to return session and user from  layout
// // don't forget to call await parent() to have fresh data
// export const load = async ({ parent } ) =>  {
//  const data = await parent()
//  const session = data.session
//  const user = data.user
//     // if there is no user's sessiion redirect back to the home page
//  if (!session) {
//      redirect(303, '/');
//  }
//   }

export const actions = {
    default: async (event) => {
    const { request, url, locals: { supabaseServerClient } } = event
        const formData = await request.formData()
        const email = formData.get('email') as string
        const password = formData.get('password') as string
    ...
    }
}

API Route

//  src/routes/+server.js
import { redirect } from '@sveltejs/kit';

export const GET = async (event) => {
    const { url, locals: { supabaseServerClient } } = event
    ...
}

Here are the "final" examples in Typescript:

// src/routes/hooks.server.ts
// SvelteKit v2
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
import { createServerClient } from '@supabase/ssr';
import type { Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) => {
    event.locals.supabaseServerClient = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
        cookies: {
            get: (key) => event.cookies.get(key),
            set: (key, value, options) => {
                event.cookies.set(key, value, { ...options, path: '/' });
            },
            remove: (key, options) => {
                event.cookies.delete(key, { ...options, path: '/' });
            }
        }
    });

    const getSessionAndUser = async () => {
        const { data: user, error: err }  = await event.locals.supabaseServerClient.auth.getUser()

      let session
       if (err) {
           return { session, user: null }
       }
       else {
         session = (await event.locals.supabaseServerClient.auth.getSession()).data?.session
      }

        return {session, user}
      }

      const {session, user} = await getSessionAndUser()

      event.locals.session = session
      event.locals.user = user

    return resolve(event, {
        filterSerializedResponseHeaders(name) {
            return name === 'content-range';
        }
    });
};
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async (event) => {
    return {
        session: event.locals.session,
        user: event.locals.user
    };
};
// src/routes/+layout.ts
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
import { combineChunks, createBrowserClient, isBrowser, parse } from '@supabase/ssr'
import type { LayoutLoad } from "./$types";

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 = combineChunks(key, (name) => {
                    const cookies = parse(document.cookie)
                    return cookies[name]
                })
                return cookie
            },
        },
    })

    return {
        supabase,
        session: data.session,
        user: data.user
    }
}
//  src/routes/+page.server.ts
import { redirect } from "@sveltejs/kit"
import type { PageServerLoad , Actions} from './$types';

export const load: PageServerLoad = async ({ locals }) => {
    // protected route with redirect if there is no user's session
    if (!locals.session) {
        redirect(303, '/');
    }

    // using Supabase server client which is stored in locals
    // locals.supabaseServerClient. ...

    return {
        session: locals.session,
        user: locals.user
    }
}

// // or if you prefere to return session and user from  layout
// // don't forget to call await parent() to have fresh data
// export const load: PageServerLoad = async ({ parent } ) =>  {
//  const data = await parent()
//  const session = data.session
//  const user = data.user
//     // if there is no user's sessiion redirect back to the home page
//  if (!session) {
//      redirect(303, '/');
//  }
//   }

export const actions: Actions = {
    default: async (event) => {
    const { request, url, locals: { supabaseServerClient } } = event
        const formData = await request.formData()
        const email = formData.get('email') as string
        const password = formData.get('password') as string
    ...
    }
}

API Route

//  src/routes/+server.ts
import { redirect } from '@sveltejs/kit';

export const GET = async (event) => {
    const { url, locals: { supabaseServerClient } } = event
    ...
}

The rest full code for login, logout, update , reset etc. is in the tutorial.

Additional context

Important issue is for example to show how to protect sensitive pages with session check and redirect (or maybe more generally in hooks.server.js). You can see this in my tutorial

charislam commented 4 months ago

Wow, great job! Love how in-depth the tutorial is.

One thing I would point out that you might want to change in your tutorial:

event.locals.getSession = async () => {
    let {
      data: { session },
    } = await event.locals.supabase.auth.getSession()

    // solving the case if the user was deleted from the database but the browser still has a cookie/loggedin user
    // +lauout.server.js will delete the cookie if the session is null
    const { data: getUserData, error: err }  = await event.locals.supabase.auth.getUser()
    if (getUserData.user == null) {
      session = null
    }

    return session
  }

As you mentioned, we have a warning in our docs that using getSession in a server environment can open a security hole in your application, since it does not guarantee that the returned session is valid.

It's better practice to call getUser() first, since getUser will check that the stored session is signed by a valid key. So instead I would do this (also returning the user object from getUser as a secure source of user data). In fact, if you only ever need the user and not the access token, you might even consider solely using getUser here:

event.locals.getSessionAndUser = async () => {
    const { data: user, error: err }  = await event.locals.supabase.auth.getUser()

  let session
   if (err) {
       return { session, user: null }
   }
   else {
     session = (await event.locals.supabase.auth.getSession()).data?.session
  }

    return {session, user}
  }
charislam commented 4 months ago

Thanks! Upon reviewing my comment, I realized I wasn't quite rigorous enough, so I've edited it, the key points being:

Really appreciate the PR! We're working through some changes to SvelteKit docs, including figuring out what we want to recommend as best practices, so I'm going to take a look at it in conjunction with that. Might take a bit longer than a quick review, but your work is definitely appreciated ❤️ and we're going to incorporate it somehow!

andreapiso commented 4 months ago

Wow, great job! Love how in-depth the tutorial is.

One thing I would point out that you might want to change in your tutorial:

event.locals.getSession = async () => {
    let {
      data: { session },
    } = await event.locals.supabase.auth.getSession()

    // solving the case if the user was deleted from the database but the browser still has a cookie/loggedin user
    // +lauout.server.js will delete the cookie if the session is null
    const { data: getUserData, error: err }  = await event.locals.supabase.auth.getUser()
    if (getUserData.user == null) {
      session = null
    }

    return session
  }

As you mentioned, we have a warning in our docs that using getSession in a server environment can open a security hole in your application, since it does not guarantee that the returned session is valid.

It's better practice to call getUser() first, since getUser will check that the stored session is signed by a valid key. So instead I would do this (also returning the user object from getUser as a secure source of user data). In fact, if you only ever need the user and not the access token, you might even consider solely using getUser here:

event.locals.getSessionAndUser = async () => {
    const { data: user, error: err }  = await event.locals.supabase.auth.getUser()

  let session
   if (err) {
       return { session, user: null }
   }
   else {
     session = (await event.locals.supabase.auth.getSession()).data?.session
  }

    return {session, user}
  }

what does this look like for typescript? The supabase documentation does not do too much of a good job explaining the types/shapes of the values returned by the auth functions...

codepainting commented 4 months ago

Somewhat related to the general discussion of Auth documentation. I do like the fact, that we can easily change the environment (Next.js, Sveltekit...) with tabs.

Not less convenient would be a way to just switch between JS and TS. I believe this is important, especially for such fragile code parts as Auth. When in doubt, JS should probably be the standard first choice.

kvetoslavnovak commented 2 months ago

As the majority of proposals are in the official documentation now I am closing this.

santoshlite commented 2 months ago

Hey I am getting a TypeError: safeGetSession is not a function when I run your code from layout.server.ts, any idea why? Thanks!

kvetoslavnovak commented 2 months ago

What is calling safeGetSession? Does this happen when you follow the code templates of updated Supabase docs https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=sveltekit&queryGroups=environment&environment=hooks ?

codepainting commented 2 months ago

@kvetoslavnovak @ssantoshp There is a longer discussion about problems surrounding this topic at: https://github.com/supabase/supabase-js/issues/1010 and https://github.com/supabase/auth-js/pull/874 No solution so far, so I am in waiting mode as well, but since this is such a complex and security critical topic, I am super the great Supabase team will figure this out soon.

kvetoslavnovak commented 2 months ago

@codepainting thank you for letting me know.