From 20ddbfb6b81584560017d7b50b037fe5ee9ca981 Mon Sep 17 00:00:00 2001 From: David Thyresson Date: Wed, 29 Mar 2023 14:56:36 -0400 Subject: [PATCH] chore: Updates Supabase readme, docs and tests for sign up with options and user/session (#7765) * Update supabase web readme. Adds session docs. * Adds test for sign up options * Docs for sign up with options * rm suspicious looking comment string * fix indentation * Corrects Sign in a user through OAuth doc example * Updated async examples to include the useEffect hooks --------- Co-authored-by: Dominic Saadi --- docs/docs/auth/supabase.md | 93 ++++- .../auth-providers/supabase/web/README.md | 370 +++++++++++++----- .../web/src/__tests__/supabase.test.tsx | 73 +++- 3 files changed, 418 insertions(+), 118 deletions(-) diff --git a/docs/docs/auth/supabase.md b/docs/docs/auth/supabase.md index 8dc8a43a972e..38a71ee59b87 100644 --- a/docs/docs/auth/supabase.md +++ b/docs/docs/auth/supabase.md @@ -1,7 +1,6 @@ --- sidebar_label: Supabase --- - # Supabase Authentication To get started, run the setup command: @@ -10,8 +9,6 @@ To get started, run the setup command: yarn rw setup auth supabase ``` - - This installs all the packages, writes all the files, and makes all the code modifications you need. For a detailed explanation of all the api- and web-side changes that aren't exclusive to Supabase, see the top-level [Authentication](../authentication.md) doc. For now, let's focus on Supabase's side of things. @@ -44,8 +41,6 @@ Lastly, in `redwood.toml`, include `SUPABASE_URL` and `SUPABASE_KEY` in the list includeEnvironmentVariables = ["SUPABASE_URL", "SUPABASE_KEY"] ``` - - ## Authentication UI Supabase doesn't redirect to a hosted sign-up page or open a sign-up modal. @@ -75,8 +70,8 @@ const HomePage = () => {

{JSON.stringify({ isAuthenticated })}

) @@ -123,6 +118,41 @@ await signUp({ }) ``` +### Sign Up with email and password and additional user metadata + +Creates a new user with additional user metadata. + +```ts +const { signUp } = useAuth() + +await signUp({ +email: 'example@email.com', + password: 'example-password', + options: { + data: { + first_name: 'John', + age: 27, + } + } +}) +``` + +### Sign Up with email and password and a redirect URL + +Creates a new user with a redirect URL. + +```ts +const { signUp } = useAuth() + +await signUp({ +email: 'example@email.com', + password: 'example-password', + options: { + emailRedirectTo: 'https://example.com/welcome' + } +}) +``` + ### Sign in a user with email and password Log in an existing user with an email and password or phone and password. @@ -171,11 +201,8 @@ Log in an existing user via a third-party provider. const { logIn } = useAuth() await logIn({ - authenticationMethod: 'otp', - email: 'example@email.com', - options: { - emailRedirectTo: 'https://example.com/welcome' - } + authMethod: 'oauth', + provider: 'github', }) ``` @@ -207,6 +234,26 @@ await logIn({ }) ``` +### Get Current User + +Gets the content of the current user set by API side authentication. + +```ts +const { currentUser } = useAuth() + +

{JSON.stringify({ currentUser })}

+``` + +### Get Current User Metadata + +Gets content of the current Supabase user session, i.e., `auth.getSession()`. + +```ts +const { userMetadata } = useAuth() + +

{JSON.stringify({ userMetadata })}

+``` + ### Sign out a user Inside a browser context, signOut() will remove the logged in user from the browser session and log them out - removing all items from localStorage and then trigger a "SIGNED_OUT" event. @@ -219,7 +266,6 @@ const { logOut } = useAuth() logOut() ``` - ### Verify and log in through OTP Log in a user given a User supplied OTP received via mobile. @@ -238,7 +284,9 @@ So, in order to use the `verifyOtp` method, you would: ```ts const { client } = useAuth() -const { data, error } = await client.verifyOtp({ phone, token, type: 'sms'}) +useEffect(() => { + const { data, error } = await client.verifyOtp({ phone, token, type: 'sms'}) +}, [client]) ``` ### Access the Supabase Auth Client @@ -251,6 +299,7 @@ const { client } = useAuth() You can then use it to work with Supabase sessions, or auth events. +When using in a React component, you'll have to put any method that needs an `await` in a `useEffect()`. ### Retrieve a session @@ -259,7 +308,9 @@ Returns the session, refreshing it if necessary. The session returned can be nul ```ts const { client } = useAuth() -const { data, error } = await client.getSession() +useEffect(() => { + const { data, error } = await client.getSession() +}, [client]) ``` ### Listen to auth events @@ -271,7 +322,13 @@ Receive a notification every time an auth event happens. ```ts const { client } = useAuth() -client.onAuthStateChange((event, session) => { - console.log(event, session) -}) +useEffect(() => { + const { data: { subscription } } = client.onAuthStateChange((event, session) => { + console.log(event, session) + }) + + return () => { + subscription.unsubscribe() + } +}, [client]) ``` diff --git a/packages/auth-providers/supabase/web/README.md b/packages/auth-providers/supabase/web/README.md index 661208df586e..b7107e802b8b 100644 --- a/packages/auth-providers/supabase/web/README.md +++ b/packages/auth-providers/supabase/web/README.md @@ -1,141 +1,331 @@ -# Authentication +# Supabase Authentication -## Contributing +To get started, run the setup command: -If you want to contribute a new auth provider integration we recommend you -start by implementing it as a custom auth provider in a Redwood App first. When -that works you can package it up as an npm package and publish it on your own. -You can then create a PR on this repo with support for your new auth provider -in our `yarn rw setup auth` cli command. The easiest option is probably to just -look at one of the existing auth providers in -`packages/cli/src/commands/setup/auth/providers` and the corresponding -templates in `../templates`. +```bash +yarn rw setup auth supabase +``` + +This installs all the packages, writes all the files, and makes all the code modifications you need. +For a detailed explanation of all the api- and web-side changes that aren't exclusive to Supabase, see the top-level [Authentication](../authentication.md) doc. For now, let's focus on Supabase's side of things. -If you need help setting up a custom auth provider you can read the auth docs -on the web. +## Setup -### Contributing to the base auth implementation +If you don't have a Supabase account yet, now's the time to make one: navigate to https://supabase.com and click "Start your project" in the top right. Then sign up and create an organization and a project. -If you want to contribute to our auth implementation, the interface towards -both auth service providers and RW apps we recommend you start looking in -`authFactory.ts` and then continue to `AuthProvider.tsx`. `AuthProvider.tsx` -has most of our implementation together with all the custom hooks it uses. -Another file to be accustomed with is `AuthContext.ts`. The interface in there -has pretty god code comments, and is what will be exposed to RW apps. +While Supabase creates your project, it thoughtfully shows your project's API keys. +(If the page refreshes while you're copying them over, just scroll down a bit and look for "Connecting to your new project".) +We're looking for "Project URL" and "API key" (the `anon`, `public` one). +Copy them into your project's `.env` file as `SUPABASE_URL` and `SUPABASE_KEY` respectively. -## getCurrentUser +There's one more we need, the "JWT Secret", that's not here. +To get that one, click the cog icon ("Project Settings") near the bottom of the nav on the left. +Then click "API", scroll down a bit, and you should see it—"JWT Secret" under "JWT Settings". +Copy it into your project's `.env` file as `SUPABASE_JWT_SECRET`. +All together now: -`getCurrentUser` returns the user information together with -an optional collection of roles used by requireAuth() to check if the user is authenticated or has role-based access. +```bash title=".env" +SUPABASE_URL="..." +SUPABASE_KEY="..." +SUPABASE_JWT_SECRET="..." +``` -Use in conjunction with `requireAuth` in your services to check that a user is logged in, whether or not they are assigned a role, and optionally raise an error if they're not. +Lastly, in `redwood.toml`, include `SUPABASE_URL` and `SUPABASE_KEY` in the list of env vars that should be available to the web side: -```js -@param decoded - The decoded access token containing user info and JWT claims like `sub` -@param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type -@param { APIGatewayEvent event, Context context } - An object which contains information from the invoker -such as headers and cookies, and the context information about the invocation such as IP Address +```toml title="redwood.toml" +[web] + # ... + includeEnvironmentVariables = ["SUPABASE_URL", "SUPABASE_KEY"] ``` -### Examples +## Authentication UI + +Supabase doesn't redirect to a hosted sign-up page or open a sign-up modal. +In a real app, you'd build a form here, but we're going to hardcode an email and password. + +### Basic Example + +After you sign up, head to your inbox: there should be a confirmation email from Supabase waiting for you. + +Click the link, then head back to your app. +Once you refresh the page, you should see `{"isAuthenticated":true}` on the page. + + +Let's make sure: if this is a brand new project, generate a home page. + +There we'll try to sign up by destructuring `signUp` from the `useAuth` hook (import that from `'src/auth'`). We'll also destructure and display `isAuthenticated` to see if it worked: + +```tsx title="web/src/pages/HomePage.tsx" +import { useAuth } from 'src/auth' -#### Checks if currentUser is authenticated +const HomePage = () => { + const { isAuthenticated, signUp } = useAuth() -This example is the standard use of `getCurrentUser`. + return ( + <> + {/* MetaTags, h1, paragraphs, etc. */} -```js -export const getCurrentUser = async (decoded, { _token, _type }, { _event, _context }) => { - return { ...decoded, roles: parseJWT({ decoded }).roles } +

{JSON.stringify({ isAuthenticated })}

+ + + ) } ``` -#### User details fetched via database query +## Authentication Reference -```js -export const getCurrentUser = async (decoded) => { - return await db.user.findUnique({ where: { decoded.email } }) -} +You will notice that [Supabase Javascript SDK Auth API](https://supabase.com/docs/reference/javascript/auth-api) reference documentation presents methods to sign in with the various integrations Supabase supports: password, OAuth, IDToken, SSO, etc. + +The RedwoodJS implementation of Supabase authentication supports these as well, but within the `logIn` method of the `useAuth` hook. + +That means that you will see that Supabase documents sign in with email password as: + +```ts +const { data, error } = await supabase.auth.signInWithPassword({ + email: 'example@email.com', + password: 'example-password', +}) ``` -#### User info is decoded from the access token +In RedwoodJS, you will always use `logIn` and pass the necessary credential options and also an `authenticationMethod` to declare how you want to authenticate. -```js -export const getCurrentUser = async (decoded) => { - return { ...decoded } -} +```ts +const { logIn } = useAuth() + +await logIn({ + authenticationMethod: 'password', + email: 'example@email.com', + password: 'example-password', +}) ``` -#### User info is contained in the decoded token and roles extracted +### Sign Up with email and password -```js -export const getCurrentUser = async (decoded) => { - return { ...decoded, roles: parseJWT({ decoded }).roles } -} +Creates a new user. + +```ts +const { signUp } = useAuth() + +await signUp({ + email: 'example@email.com', + password: 'example-password', +}) ``` -#### User record query by email with namespaced app_metadata roles as Auth0 requires custom JWT claims to be namespaced +### Sign Up with email and password and additional user metadata -```js -export const getCurrentUser = async (decoded) => { - const currentUser = await db.user.findUnique({ where: { email: decoded.email } }) +Creates a new user with additional user metadata. - return { - ...currentUser, - roles: parseJWT({ decoded: decoded, namespace: NAMESPACE }).roles, +```ts +const { signUp } = useAuth() + +await signUp({ +email: 'example@email.com', + password: 'example-password', + options: { + data: { + first_name: 'John', + age: 27, + } } -} +}) ``` -#### User record query by an identity with app_metadata roles +### Sign Up with email and password and a redirect URL -```js -const getCurrentUser = async (decoded) => { - const currentUser = await db.user.findUnique({ where: { userIdentity: decoded.sub } }) - return { - ...currentUser, - roles: parseJWT({ decoded: decoded }).roles, +Creates a new user with a redirect URL. + +```ts +const { signUp } = useAuth() + +await signUp({ +email: 'example@email.com', + password: 'example-password', + options: { + emailRedirectTo: 'https://example.com/welcome' } -} +}) ``` -#### Cookies and other request information are available in the req parameter, just in case +### Sign in a user with email and password -```js -const getCurrentUser = async (_decoded, _raw, { event, _context }) => { - const cookies = cookie(event.headers.cookies) - const session = cookies['my.cookie.name'] - const currentUser = await db.sessions.findUnique({ where: { id: session } }) - return currentUser -} +Log in an existing user with an email and password or phone and password. + +* Requires either an email and password or a phone number and password. + +```ts +const { logIn } = useAuth() + +await logIn({ + authenticationMethod: 'password', + email: 'example@email.com', + password: 'example-password', +}) +``` + +### Sign in a user through Passwordless/OTP + +Log in a user using magiclink or a one-time password (OTP). + +* Requires either an email or phone number. + +* This method is used for passwordless sign-ins where a OTP is sent to the user's email or phone number. + +```ts +const { logIn } = useAuth() + +await logIn({ + authenticationMethod: 'otp', + email: 'example@email.com', + options: { + emailRedirectTo: 'https://example.com/welcome' + } +}) +``` + +### Sign in a user through OAuth + +Log in an existing user via a third-party provider. + +* This method is used for signing in using a third-party provider. + +* Supabase supports many different [third-party providers](https://supabase.com/docs/guides/auth#providers). + +```ts +const { logIn } = useAuth() + +await logIn({ + authMethod: 'oauth', + provider: 'github', +}) +``` + +### Sign in a user with IDToken + +Log in a user using IDToken. + +```ts +const { logIn } = useAuth() + +await logIn({ + authenticationMethod: 'id_token', + provider: 'apple', + token: 'cortland-apple-id-token', +}) +``` + +### Sign in a user with SSO + +Log in a user using IDToken. + +```ts +const { logIn } = useAuth() + +await logIn({ + authenticationMethod: 'sso', + providerId: 'sso-provider-identity-uuid', + domain: 'example.com', +}) ``` +### Get Current User -## requireAuth +Gets the content of the current user set by API side authentication. - Use `requireAuth` in your services to check that a user is logged in, whether or not they are assigned a role, and optionally raise an error if they're not. +```ts +const { currentUser } = useAuth() -```js -@param {string=} roles - An optional role or list of roles -@param {string[]=} roles - An optional list of roles +

{JSON.stringify({ currentUser })}

+``` + +### Get Current User Metadata + +Gets content of the current Supabase user session, i.e., `auth.getSession()`. -@returns {boolean} - If the currentUser is authenticated (and assigned one of the given roles) +```ts +const { userMetadata } = useAuth() -@throws {AuthenticationError} - If the currentUser is not authenticated -@throws {ForbiddenError} If the currentUser is not allowed due to role permissions +

{JSON.stringify({ userMetadata })}

``` -### Examples +### Sign out a user -#### Checks if currentUser is authenticated +Inside a browser context, signOut() will remove the logged in user from the browser session and log them out - removing all items from localStorage and then trigger a "SIGNED_OUT" event. -```js -requireAuth() +In order to use the signOut() method, the user needs to be signed in first. + +```ts +const { logOut } = useAuth() + +logOut() ``` -#### Checks if currentUser is authenticated and assigned one of the given roles +### Verify and log in through OTP + +Log in a user given a User supplied OTP received via mobile. + +* The verifyOtp method takes in different verification types. If a phone number is used, the type can either be sms or phone_change. If an email address is used, the type can be one of the following: signup, magiclink, recovery, invite or email_change. + +* The verification type used should be determined based on the corresponding auth method called before verifyOtp to sign up / sign-in a user. + + +The RedwoodJS auth provider doesn't expose the `veriftyOtp` method from the Supabase SDK directly. + +Instead, since you always have access the the Supabase Auth client, you can access any method it exposes. -```js - requireAuth({ role: 'admin' }) - requireAuth({ role: ['editor', 'author'] }) - requireAuth({ role: ['publisher'] }) +So, in order to use the `verifyOtp` method, you would: + +```ts +const { client } = useAuth() + +useEffect(() => { + const { data, error } = await client.verifyOtp({ phone, token, type: 'sms'}) +}, [client]) +``` + +### Access the Supabase Auth Client + +Sometimes you may need to access the Supabase Auth client directly. + +```ts +const { client } = useAuth() +``` + +You can then use it to work with Supabase sessions, or auth events. + +When using in a React component, you'll have to put any method that needs an `await` in a `useEffect()`. + +### Retrieve a session + +Returns the session, refreshing it if necessary. The session returned can be null if the session is not detected which can happen in the event a user is not signed-in or has logged out. + +```ts +const { client } = useAuth() + +useEffect(() => { + const { data, error } = await client.getSession() +}, [client]) +``` + +### Listen to auth events + +Receive a notification every time an auth event happens. + +* Types of auth events: `SIGNED_IN`, `SIGNED_OUT`, `TOKEN_REFRESHED`, `USER_UPDATED`, `PASSWORD_RECOVERY` + +```ts +const { client } = useAuth() + +useEffect(() => { + const { data: { subscription } } = client.onAuthStateChange((event, session) => { + console.log(event, session) + }) + + return () => { + subscription.unsubscribe() + } +}, [client]) ``` diff --git a/packages/auth-providers/supabase/web/src/__tests__/supabase.test.tsx b/packages/auth-providers/supabase/web/src/__tests__/supabase.test.tsx index d379d94f6fc2..d45ff78107f9 100644 --- a/packages/auth-providers/supabase/web/src/__tests__/supabase.test.tsx +++ b/packages/auth-providers/supabase/web/src/__tests__/supabase.test.tsx @@ -159,12 +159,19 @@ const mockSupabaseAuthClient: Partial = { signUp: async ( credentials: SignUpWithPasswordCredentials ): Promise => { - const { email } = credentials as { email: string } + const { email } = credentials as { + email: string + } loggedInUser = email === 'admin@example.com' ? adminUser : user loggedInUser.email = email + if (credentials.options) { + loggedInUser.user_metadata = credentials.options + } + + console.log('signUp', loggedInUser) return { data: { user: loggedInUser, @@ -306,20 +313,66 @@ describe('Supabase Authentication', () => { }) describe('Password Authentication', () => { - it('is authenticated after signing up', async () => { - const authRef = getSupabaseAuth() + describe('Sign up', () => { + it('is authenticated after signing up with username and password', async () => { + const authRef = getSupabaseAuth() + + await act(async () => { + authRef.current.signUp({ + email: 'jane.doe@example.com', + password: 'ThereIsNoSpoon', + }) + }) - await act(async () => { - authRef.current.signUp({ - email: 'jane.doe@example.com', - password: 'ThereIsNoSpoon', + const currentUser = authRef.current.currentUser + + expect(authRef.current.isAuthenticated).toBeTruthy() + expect(currentUser?.email).toEqual('jane.doe@example.com') + }) + + it('is authenticated after signing up with username and password and additional metadata', async () => { + const authRef = getSupabaseAuth() + + await act(async () => { + authRef.current.signUp({ + email: 'jane.doe@example.com', + password: 'ThereIsNoSpoon', + options: { + data: { + first_name: 'Jane', + age: 27, + }, + }, + }) }) + + const currentUser = authRef.current.currentUser + const userMetadata = authRef.current.userMetadata + + expect(authRef.current.isAuthenticated).toBeTruthy() + expect(currentUser?.email).toEqual('jane.doe@example.com') + expect(userMetadata?.data?.first_name).toEqual('Jane') + expect(userMetadata?.data?.age).toEqual(27) }) - const currentUser = authRef.current.currentUser + it('is authenticated after signing up with username and password and a redirect URL', async () => { + const authRef = getSupabaseAuth() - expect(authRef.current.isAuthenticated).toBeTruthy() - expect(currentUser?.email).toEqual('jane.doe@example.com') + await act(async () => { + authRef.current.signUp({ + email: 'example@email.com', + password: 'example-password', + options: { + emailRedirectTo: 'https://example.com/welcome', + }, + }) + }) + + const currentUser = authRef.current.currentUser + + expect(authRef.current.isAuthenticated).toBeTruthy() + expect(currentUser?.email).toEqual('example@email.com') + }) }) it('is authenticated after logging in', async () => {