From 2725584ea2bb4c974d45f0ce11d701ab1ecee115 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thor=20=E9=9B=B7=E7=A5=9E=20Schaeff?= <5748289+thorwebdev@users.noreply.github.com> Date: Tue, 22 Feb 2022 17:25:52 +0800 Subject: [PATCH] fix: withAuthRequired for API routes. (#27) * fix: remove onUserLoaded mechanism. * fix: withAuthRequired for API routes. --- CHANGELOG.md | 5 + examples/nextjs/pages/_app.tsx | 16 +- examples/nextjs/pages/api/hello.ts | 13 -- examples/nextjs/pages/api/protected-route.ts | 13 ++ examples/nextjs/pages/index.tsx | 15 +- examples/nextjs/pages/profile.tsx | 3 - src/nextjs/README.md | 55 +++-- src/nextjs/utils/withAuthRequired.ts | 202 ++++++++++++------- src/react/components/UserProvider.tsx | 39 +--- 9 files changed, 185 insertions(+), 176 deletions(-) delete mode 100644 examples/nextjs/pages/api/hello.ts create mode 100644 examples/nextjs/pages/api/protected-route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 70b2f2ae..7d1df497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # CHANGELOG +## 1.1.2 - 2022-02-22 + +- Makes `withAuthRequired` work for API routes as well. See [the docs](./src/nextjs/README.md#protecting-api-routes) for more details. +- Removes `onUserLoaded` prop as it was rather confusing and user might want to choose other ways to manage their user state rather than React context. + ## 1.1.1 - 2022-02-22 - [#24](https://github.com/supabase-community/supabase-auth-helpers/pull/24): feat: onUserLoaded prop in UserProvider: diff --git a/examples/nextjs/pages/_app.tsx b/examples/nextjs/pages/_app.tsx index 64e9c296..d03c813d 100644 --- a/examples/nextjs/pages/_app.tsx +++ b/examples/nextjs/pages/_app.tsx @@ -1,23 +1,11 @@ import '../styles/globals.css'; import type { AppProps } from 'next/app'; import { UserProvider } from '@supabase/supabase-auth-helpers/react'; -import { - supabaseClient, - SupabaseClient -} from '@supabase/supabase-auth-helpers/nextjs'; +import { supabaseClient } from '@supabase/supabase-auth-helpers/nextjs'; -// You can pass an onUserLoaded method to fetch additional data from your public scema. -// This data will be available as the `onUserLoadedData` prop in the `useUser` hook. function MyApp({ Component, pageProps }: AppProps) { return ( - { - // Since supabase is so fast, we need a 2s sleep here to test that it's working :D - await new Promise((r) => setTimeout(r, 2000)); - return (await supabaseClient.from('test').select('*').single()).data; - }} - > + ); diff --git a/examples/nextjs/pages/api/hello.ts b/examples/nextjs/pages/api/hello.ts deleted file mode 100644 index f8bcc7e5..00000000 --- a/examples/nextjs/pages/api/hello.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Next.js API route support: https://nextjs.org/docs/api-routes/introduction -import type { NextApiRequest, NextApiResponse } from 'next' - -type Data = { - name: string -} - -export default function handler( - req: NextApiRequest, - res: NextApiResponse -) { - res.status(200).json({ name: 'John Doe' }) -} diff --git a/examples/nextjs/pages/api/protected-route.ts b/examples/nextjs/pages/api/protected-route.ts new file mode 100644 index 00000000..4a0d3b4e --- /dev/null +++ b/examples/nextjs/pages/api/protected-route.ts @@ -0,0 +1,13 @@ +// pages/api/protected-route.ts +import { + withAuthRequired, + supabaseServerClient +} from '@supabase/supabase-auth-helpers/nextjs'; + +export default withAuthRequired(async function ProtectedRoute(req, res) { + // Run queries with RLS on the server + const { data } = await supabaseServerClient({ req, res }) + .from('test') + .select('*'); + res.json(data); +}); diff --git a/examples/nextjs/pages/index.tsx b/examples/nextjs/pages/index.tsx index 0b721e48..c896181e 100644 --- a/examples/nextjs/pages/index.tsx +++ b/examples/nextjs/pages/index.tsx @@ -2,14 +2,25 @@ import { useUser, Auth } from '@supabase/supabase-auth-helpers/react'; import { supabaseClient } from '@supabase/supabase-auth-helpers/nextjs'; import type { NextPage } from 'next'; import Link from 'next/link'; +import { useEffect, useState } from 'react'; const LoginPage: NextPage = () => { - const { isLoading, user, onUserLoadedData, error } = useUser(); + const { isLoading, user, error } = useUser(); + const [data, setData] = useState(null); + + useEffect(() => { + async function loadData() { + const { data } = await supabaseClient.from('test').select('*').single(); + setData(data); + } + if (user) loadData(); + }, [user]); if (!user) return ( <> {error &&

{error.message}

} + {isLoading ?

Loading...

:

Loaded!

} {

user:

{JSON.stringify(user, null, 2)}

client-side data fetching with RLS

-
{JSON.stringify(onUserLoadedData, null, 2)}
+
{JSON.stringify(data, null, 2)}
); }; diff --git a/examples/nextjs/pages/profile.tsx b/examples/nextjs/pages/profile.tsx index 28ceba6d..530ea037 100644 --- a/examples/nextjs/pages/profile.tsx +++ b/examples/nextjs/pages/profile.tsx @@ -1,10 +1,8 @@ // pages/profile.js import { withAuthRequired, User } from '@supabase/supabase-auth-helpers/nextjs'; -import { useUser } from '@supabase/supabase-auth-helpers/react'; import Link from 'next/link'; export default function Profile({ user }: { user: User }) { - const { onUserLoadedData } = useUser(); return ( <>

@@ -13,7 +11,6 @@ export default function Profile({ user }: { user: User }) {

Hello {user.email}
{JSON.stringify(user, null, 2)}
-
{JSON.stringify(onUserLoadedData, null, 2)}
); } diff --git a/src/nextjs/README.md b/src/nextjs/README.md index f1b8d0d0..3196acd8 100644 --- a/src/nextjs/README.md +++ b/src/nextjs/README.md @@ -67,38 +67,6 @@ export default function App({ Component, pageProps }) { You can now determine if a user is authenticated by checking that the `user` object returned by the `useUser()` hook is defined. -## Loading additional user data - -The `user` object from the `useUser()` hook is only meant to be used as an indicator that the user is signed in, you're not meant to store additional user information in this object but rather you're meant to store additional information in your `public.users` table. See [the "Managing User Data" docs](https://supabase.com/docs/guides/auth/managing-user-data) for more details on this. - -You can conveniently make your additional user data available in the `useUser()` hook but setting the `onUserDataLoaded` prop on the `UserProvider` components: - -```js -import type { AppProps } from 'next/app'; -import { UserProvider } from '@supabase/supabase-auth-helpers/react'; -import { - supabaseClient, - SupabaseClient -} from '@supabase/supabase-auth-helpers/nextjs'; - -// You can pass an onUserLoaded method to fetch additional data from your public schema. -// This data will be available as the `onUserLoadedData` prop in the `useUser` hook. -function MyApp({ Component, pageProps }: AppProps) { - return ( - - (await supabaseClient.from('users').select('*').single()).data - } - > - - - ); -} - -export default MyApp; -``` - ## Client-side data fetching with RLS For [row level security](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security) to work properly when fetching data client-side, you need to make sure to import the `{ supabaseClient }` from `# @supabase/supabase-auth-helpers/nextjs` and only run your query once the user is defined client-side in the `useUser()` hook: @@ -222,3 +190,26 @@ export const getServerSideProps = withAuthRequired({ } }); ``` + +## Protecting API routes + +Wrap an API Route to check that the user has a valid session. If they're not logged in the handler will return a +401 Unauthorized. + +```js +// pages/api/protected-route.js +import { + withAuthRequired, + supabaseServerClient +} from '@supabase/supabase-auth-helpers/nextjs'; + +export default withAuthRequired(async function ProtectedRoute(req, res) { + // Run queries with RLS on the server + const { data } = await supabaseServerClient({ req, res }) + .from('test') + .select('*'); + res.json(data); +}); +``` + +If you visit `/api/protected-route` without a valid session cookie, you will get a 401 response. diff --git a/src/nextjs/utils/withAuthRequired.ts b/src/nextjs/utils/withAuthRequired.ts index 32bbb5e5..631770e3 100644 --- a/src/nextjs/utils/withAuthRequired.ts +++ b/src/nextjs/utils/withAuthRequired.ts @@ -1,10 +1,18 @@ -import { GetServerSideProps, GetServerSidePropsContext } from 'next'; +import { + GetServerSideProps, + GetServerSidePropsContext, + NextApiHandler, + NextApiRequest, + NextApiResponse +} from 'next'; import { jwtDecoder } from '../../shared/utils/jwt'; import { CookieOptions } from '../types'; import { COOKIE_OPTIONS } from './constants'; +import getAccessToken from './getAccessToken'; import getUser from './getUser'; /** + * ## Protecting Pages with Server Side Rendering (SSR) * If you wrap your `getServerSideProps` with {@link withAuthRequired} your props object will be augmented with * the user object {@link User} * @@ -35,93 +43,133 @@ import getUser from './getUser'; * export const getServerSideProps = withAuthRequired({ * redirectTo: '/foo', * async getServerSideProps(ctx) { - * // Access the user object - * const { user, accessToken } = await getUser(ctx); - * return { props: { email: user!.email } }; + * // Run queries with RLS on the server + * const { data } = await supabaseServerClient(ctx).from('test').select('*'); + * return { props: { data } }; * } * }); * ``` * + * ## Protecting API routes + * Wrap an API Route to check that the user has a valid session. If they're not logged in the handler will return a + * 401 Unauthorized. + * + * ```js + * // pages/api/protected-route.js + * import { withAuthRequired, supabaseServerClient } from '@supabase/supabase-auth-helpers/nextjs'; + * + * export default withAuthRequired(async function ProtectedRoute(req, res) { + * // Run queries with RLS on the server + * const { data } = await supabaseServerClient({ req, res }).from('test').select('*'); + * res.json(data) + * }); + * ``` + * + * If you visit `/api/protected-route` without a valid session cookie, you will get a 401 response. + * * @category Server */ + +export type WithAuthRequiredArg = + | { + redirectTo?: string; + getServerSideProps?: GetServerSideProps; + cookieOptions?: CookieOptions; + } + | NextApiHandler; + export default function withAuthRequired( - options: { - redirectTo?: string; - getServerSideProps?: GetServerSideProps; - cookieOptions?: CookieOptions; - } = {} + arg?: WithAuthRequiredArg, + cookieOptions = COOKIE_OPTIONS ) { - const { - getServerSideProps, - redirectTo = '/', - cookieOptions = COOKIE_OPTIONS - } = options; - return async (context: GetServerSidePropsContext) => { - try { - if (!context.req.cookies) { - throw new Error('Not able to parse cookies!'); - } - const access_token = - context.req.cookies[`${cookieOptions.name}-access-token`]; - if (!access_token) { - throw new Error('No cookie found!'); + if (typeof arg === 'function') { + return async (req: NextApiRequest, res: NextApiResponse): Promise => { + try { + const accessToken = await getAccessToken({ req, res }, cookieOptions); + if (!accessToken) throw new Error('No access token!'); + await arg(req, res); + } catch (error) { + res.status(401).json({ + error: 'not_authenticated', + description: + 'The user does not have an active session or is not authenticated' + }); + return; } + }; + } else { + const { + getServerSideProps = undefined, + redirectTo = '/', + cookieOptions = COOKIE_OPTIONS + } = arg ? arg : {}; + return async (context: GetServerSidePropsContext) => { + try { + if (!context.req.cookies) { + throw new Error('Not able to parse cookies!'); + } + const access_token = + context.req.cookies[`${cookieOptions.name}-access-token`]; + if (!access_token) { + throw new Error('No cookie found!'); + } - let user, accessToken; - // Get payload from cached access token. - const jwtUser = jwtDecoder(access_token); - if (!jwtUser?.exp) { - throw new Error('Not able to parse JWT payload!'); - } - const timeNow = Math.round(Date.now() / 1000); - if (jwtUser.exp < timeNow) { - // JWT is expired, let's refresh from Gotrue - const response = await getUser(context, cookieOptions); - user = response.user; - accessToken = response.accessToken; - } else { - // Transform JWT and add note that it ise cached from JWT. - user = { - id: jwtUser.sub, - aud: null, - role: null, - email: null, - email_confirmed_at: null, - phone: null, - confirmed_at: null, - last_sign_in_at: null, - app_metadata: {}, - user_metadata: {}, - identities: [], - created_at: null, - updated_at: null, - 'supabase-auth-helpers-note': - 'This user payload is retrieved from the cached JWT and might be stale. If you need up to date user data, please call the `getUser` method in a server-side context!' - }; - const mergedUser = { ...user, ...jwtUser }; - user = mergedUser; - accessToken = access_token; - } + let user, accessToken; + // Get payload from cached access token. + const jwtUser = jwtDecoder(access_token); + if (!jwtUser?.exp) { + throw new Error('Not able to parse JWT payload!'); + } + const timeNow = Math.round(Date.now() / 1000); + if (jwtUser.exp < timeNow) { + // JWT is expired, let's refresh from Gotrue + const response = await getUser(context, cookieOptions); + user = response.user; + accessToken = response.accessToken; + } else { + // Transform JWT and add note that it ise cached from JWT. + user = { + id: jwtUser.sub, + aud: null, + role: null, + email: null, + email_confirmed_at: null, + phone: null, + confirmed_at: null, + last_sign_in_at: null, + app_metadata: {}, + user_metadata: {}, + identities: [], + created_at: null, + updated_at: null, + 'supabase-auth-helpers-note': + 'This user payload is retrieved from the cached JWT and might be stale. If you need up to date user data, please call the `getUser` method in a server-side context!' + }; + const mergedUser = { ...user, ...jwtUser }; + user = mergedUser; + accessToken = access_token; + } - if (!user) { - throw new Error('No user found!'); - } + if (!user) { + throw new Error('No user found!'); + } - let ret: any = { props: {} }; - if (getServerSideProps) { - ret = await getServerSideProps(context); - } - return { - ...ret, - props: { ...ret.props, user: user, accessToken: accessToken } - }; - } catch (e) { - return { - redirect: { - destination: redirectTo, - permanent: false + let ret: any = { props: {} }; + if (getServerSideProps) { + ret = await getServerSideProps(context); } - }; - } - }; + return { + ...ret, + props: { ...ret.props, user: user, accessToken: accessToken } + }; + } catch (e) { + return { + redirect: { + destination: redirectTo, + permanent: false + } + }; + } + }; + } } diff --git a/src/react/components/UserProvider.tsx b/src/react/components/UserProvider.tsx index c77a7359..1c9da099 100644 --- a/src/react/components/UserProvider.tsx +++ b/src/react/components/UserProvider.tsx @@ -10,18 +10,12 @@ import { SupabaseClient, User } from '@supabase/supabase-js'; export type UserState = { user: User | null; - onUserLoadedData: any | null; accessToken: string | null; error?: Error; isLoading: boolean; }; -const UserContext = createContext({ - user: null, - onUserLoadedData: null, - accessToken: null, - isLoading: true -}); +const UserContext = createContext(undefined); type UserFetcher = ( url: string @@ -37,7 +31,6 @@ export interface Props { profileUrl?: string; user?: User; fetcher?: UserFetcher; - onUserLoaded?: (supabaseClient: SupabaseClient) => Promise; [propName: string]: any; } @@ -47,14 +40,12 @@ export const UserProvider = (props: Props) => { callbackUrl = '/api/auth/callback', profileUrl = '/api/auth/user', user: initialUser = null, - fetcher = userFetcher, - onUserLoaded + fetcher = userFetcher } = props; const [user, setUser] = useState(initialUser); const [accessToken, setAccessToken] = useState(null); const [isLoading, setIsLoading] = useState(!initialUser); const [error, setError] = useState(); - const [onUserLoadedData, setOnUserLoadedData] = useState(null); const { pathname } = useRouter(); const checkSession = useCallback(async (): Promise => { @@ -65,6 +56,7 @@ export const UserProvider = (props: Props) => { setAccessToken(accessToken); } setUser(user); + if (!user) setIsLoading(false); } catch (_e) { const error = new Error(`The request to ${profileUrl} failed`); setError(error); @@ -76,33 +68,11 @@ export const UserProvider = (props: Props) => { async function runOnPathChange() { setIsLoading(true); await checkSession(); - if (onUserLoadedData || !onUserLoaded) { - setIsLoading(false); - } + setIsLoading(false); } runOnPathChange(); }, [pathname]); - // Only load user Data the first time after user is loaded. - useEffect(() => { - async function loadUserData() { - if (onUserLoaded && !onUserLoadedData) { - try { - const response = await onUserLoaded(supabaseClient); - setOnUserLoadedData(response); - setIsLoading(false); - } catch (error) { - console.log('Error in your `onUserLoaded` method:', error); - } - } - } - if (user) { - loadUserData(); - } else { - setOnUserLoadedData(null); - } - }, [user, accessToken]); - useEffect(() => { const { data: authListener } = supabaseClient.auth.onAuthStateChange( async (event, session) => { @@ -135,7 +105,6 @@ export const UserProvider = (props: Props) => { const value = { isLoading, user, - onUserLoadedData, accessToken, error };